History

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

fix(antigravity): preserve response and tool schema semantics

Luis Pater committed 2 months ago

Commit 361fe952 parents 7eefab9 7d55d0d4 files · +487 −143Browse files

Unified · Split

4 files
  1. internal/runtime/executor/antigravity_executor_request.go +2 −3
  2. internal/runtime/executor/antigravity_schema_sanitize_test.go +34 −10
  3. internal/util/gemini_schema.go +297 −58
  4. internal/util/gemini_schema_test.go +154 −72
internal/runtime/executor/antigravity_executor_request.go+2−3
@@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche · 204 unmodified lines
205 payloadStr = renamed205 payloadStr = renamed
206 }206 }
207 207
208 toolSchemaCleaner := util.CleanJSONSchemaForGemini208 toolSchemaCleaner := func(schema string) string {
209 if useAntigravitySchema {209 return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
210 toolSchemaCleaner = util.CleanJSONSchemaForAntigravity
211 }210 }
212 cleanNestedToolSchema := func(schemaRaw string) string {211 cleanNestedToolSchema := func(schemaRaw string) string {
213 return cleanNestedSchema(toolSchemaCleaner, schemaRaw)212 return cleanNestedSchema(toolSchemaCleaner, schemaRaw)
internal/runtime/executor/antigravity_schema_sanitize_test.go+34−10
@@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing · 221 unmodified lines
222 for _, useAntigravitySchema := range []bool{false, true} {222 for _, useAntigravitySchema := range []bool{false, true} {
223 for name, schema := range shapes {223 for name, schema := range shapes {
224 doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`224 doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`
225 whole := util.CleanJSONSchemaForGemini(doc)225 whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema)
226 if useAntigravitySchema {
227 whole = util.CleanJSONSchemaForAntigravity(doc)
228 }
229 want := gjson.Get(whole, schemaPath).Raw226 want := gjson.Get(whole, schemaPath).Raw
230 got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw227 got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
231 if want != got {228 if want != got {
@@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t · 42 unmodified lines
274 }271 }
275}272}
276 273
277func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) {274func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) {
278 payload := `{"request":{275 payload := `{"request":{
279 "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{276 "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
280 "choice":{"anyOf":[{"type":"string"},{"type":"null"}]},277 "choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
@@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 10 unmodified lines
291 288
292 got := sanitizeAntigravityRequestSchemas(payload, true)289 got := sanitizeAntigravityRequestSchemas(payload, true)
293 responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")290 responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
294 union := responseSchema.Get("properties.action.anyOf")291 action := responseSchema.Get("properties.action")
295 if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" {292 if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() {
296 t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw)293 t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw)
297 }294 }
298 conviction := responseSchema.Get("properties.conviction")295 conviction := responseSchema.Get("properties.conviction")
299 if gotType := conviction.Get("type").String(); gotType != "number" {296 if gotType := conviction.Get("type").String(); gotType != "number" {
@@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t · 9 unmodified lines
309 if toolSchema.Get("properties.choice.anyOf").Exists() {306 if toolSchema.Get("properties.choice.anyOf").Exists() {
310 t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)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" {309 if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
313 t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw)310 t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
311 }
312}
313
314func 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
internal/util/gemini_schema.go+297−58
@@ -2,6 +2,7 @@ · 1 unmodified line
2package util2package util
3 3
4import (4import (
5 "encoding/json"
5 "fmt"6 "fmt"
6 "sort"7 "sort"
7 "strconv"8 "strconv"
@@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t · 18 unmodified lines
26 27
27type jsonSchemaCleanOptions struct {28type jsonSchemaCleanOptions struct {
28 addPlaceholder bool29 addPlaceholder bool
30 antigravitySemantics bool
31 removeToolTitle bool
29 removeGeminiMetadata bool32 removeGeminiMetadata bool
30 flattenUnions bool33 flattenUnions bool
31 forceEnumStringType bool34 forceEnumStringType bool
35 dropAllEnums bool
36 dropBooleanEnums bool
32 preserveAdditionalPropertiesFalse bool37 preserveAdditionalPropertiesFalse bool
33}38}
34 39
@@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct { · 1 unmodified line
36// It handles unsupported keywords, type flattening, and schema simplification while preserving41// It handles unsupported keywords, type flattening, and schema simplification while preserving
37// semantic information as description hints and adding placeholders required by VALIDATED mode.42// semantic information as description hints and adding placeholders required by VALIDATED mode.
38func CleanJSONSchemaForAntigravity(jsonStr string) string {43func 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.
51func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
39 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{52 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
40 addPlaceholder: true,53 addPlaceholder: requirePlaceholder,
41 flattenUnions: true,54 antigravitySemantics: true,
42 forceEnumStringType: 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// compatibility rewrites that would alter the client's structured output contract.62// compatibility rewrites that would alter the client's structured output contract.
48//63//
49// Sanitization policy:64// Sanitization policy:
50// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf),65// - Passthrough: type, properties, items, required, description, enum, nullable, and
51// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint).66// additionalProperties: false (which Antigravity natively enforces for response schemas).
52// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems,67// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
53// maxItems, uniqueItems, format, default, examples).
54// - Flattened: allOf merged into properties/required.68// - Flattened: allOf merged into properties/required.
55// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint),69// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
56// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties,70// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
57// and x-* extensions.71// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
72// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
58func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {73func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
59 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{74 return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
75 antigravitySemantics: true,
76 flattenUnions: true,
77 dropBooleanEnums: true,
60 preserveAdditionalPropertiesFalse: true,78 preserveAdditionalPropertiesFalse: true,
61 })79 })
62}80}
@@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string { · 11 unmodified lines
74// cleanJSONSchema performs the core cleaning operations on the JSON schema.92// cleanJSONSchema performs the core cleaning operations on the JSON schema.
75func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {93func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
76 // Phase 1: Convert and add hints94 // 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 jsonStr = convertConstToEnum(jsonStr)99 jsonStr = convertConstToEnum(jsonStr)
79 jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)100 jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
80 jsonStr = addEnumHints(jsonStr)101 jsonStr = addEnumHints(jsonStr)
102 jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
81 if !options.preserveAdditionalPropertiesFalse {103 if !options.preserveAdditionalPropertiesFalse {
82 jsonStr = addAdditionalPropertiesHints(jsonStr)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 // Phase 2: Flatten complex structures111 // Phase 2: Flatten complex structures
87 jsonStr = mergeConditionals(jsonStr)112 jsonStr = mergeConditionals(jsonStr)
@@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line
89 if options.flattenUnions {114 if options.flattenUnions {
90 jsonStr = flattenAnyOfOneOf(jsonStr)115 jsonStr = flattenAnyOfOneOf(jsonStr)
91 }116 }
92 jsonStr = flattenTypeArrays(jsonStr)117 jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
93 118
94 // Phase 3: Cleanup119 // Phase 3: Cleanup
95 jsonStr = removeUnsupportedKeywords(jsonStr, options)120 jsonStr = removeUnsupportedKeywords(jsonStr, options)
@@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line
97 // Gemini schema cleanup: remove nullable/title and placeholder-only fields.122 // Gemini schema cleanup: remove nullable/title and placeholder-only fields.
98 jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})123 jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
99 jsonStr = removePlaceholderFields(jsonStr)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 jsonStr = cleanupRequiredFields(jsonStr)130 jsonStr = cleanupRequiredFields(jsonStr)
102 // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)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 return jsonStr222 return jsonStr
194}223}
195 224
196// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).225// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
197func convertRefsToHints(jsonStr string) string {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.
228func 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
248func 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
292func 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
316func 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
340func 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.
349func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
198 paths := findPaths(jsonStr, "$ref")350 paths := findPaths(jsonStr, "$ref")
199 sortByDepth(paths)351 sortByDepth(paths)
200 352
201 for _, p := range paths {353 for _, p := range paths {
202 refVal := gjson.Get(jsonStr, p).String()354 refVal := gjson.Get(jsonStr, p).String()
203 defName := refVal355 defName := refName(refVal)
204 if idx := strings.LastIndex(refVal, "/"); idx >= 0 {
205 defName = refVal[idx+1:]
206 }
207 356
208 parentPath := trimSuffix(p, ".$ref")357 parentPath := trimSuffix(p, ".$ref")
209 hint := fmt.Sprintf("See: %s", defName)358 hint := fmt.Sprintf("See: %s", defName)
210 if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {359 if !preserveSiblings {
211 hint = fmt.Sprintf("%s (%s)", existing, hint)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 368 jsonStr, _ = sjson.Delete(jsonStr, p)
214 replacement := `{"type":"object","description":""}`369 jsonStr = appendHint(jsonStr, parentPath, hint)
215 replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
216 replacement = string(replacementBytes)
217 jsonStr = setRawAt(jsonStr, parentPath, replacement)
218 }370 }
219 return jsonStr371 return jsonStr
220}372}
@@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string { · 14 unmodified lines
235}387}
236 388
237// convertEnumValuesToStrings ensures all enum values use the string representation required by389// 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 preserve390// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
239// their declared type because the upstream decoder uses it to select the emitted JSON value type.391// emitted JSON type on both response and function-argument paths.
240func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {392func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
241 for _, p := range findPaths(jsonStr, "enum") {393 for _, p := range findPaths(jsonStr, "enum") {
242 arr := gjson.Get(jsonStr, p)394 arr := gjson.Get(jsonStr, p)
@@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string { · 37 unmodified lines
280 return jsonStr432 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.
438func 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
283func addAdditionalPropertiesHints(jsonStr string) string {454func addAdditionalPropertiesHints(jsonStr string) string {
284 for _, p := range findPaths(jsonStr, "additionalProperties") {455 for _, p := range findPaths(jsonStr, "additionalProperties") {
285 if gjson.Get(jsonStr, p).Type == gjson.False {456 if gjson.Get(jsonStr, p).Type == gjson.False {
@@ -295,9 +466,18 @@ var unsupportedConstraints = []string{ · 9 unmodified lines
295 "default", "examples", // Claude rejects these in VALIDATED mode466 "default", "examples", // Claude rejects these in VALIDATED mode
296}467}
297 468
298func moveConstraintsToDescription(jsonStr string) string {469func constraintKeywords(options jsonSchemaCleanOptions) []string {
299 pathsByField := findPathsByFields(jsonStr, unsupportedConstraints)470 keywords := append([]string(nil), unsupportedConstraints...)
300 for _, key := range unsupportedConstraints {471 if options.antigravitySemantics {
472 keywords = append(keywords, "minimum", "maximum", "multipleOf")
473 }
474 return keywords
475}
476
477func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
478 constraints := constraintKeywords(options)
479 pathsByField := findPathsByFields(jsonStr, constraints)
480 for _, key := range constraints {
301 for _, p := range pathsByField[key] {481 for _, p := range pathsByField[key] {
302 val := gjson.Get(jsonStr, p)482 val := gjson.Get(jsonStr, p)
303 if !val.Exists() || val.IsObject() || val.IsArray() {483 if !val.Exists() || val.IsObject() || val.IsArray() {
@@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string { · 9 unmodified lines
313 return jsonStr493 return jsonStr
314}494}
315 495
496func 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
316func mergeConditionals(jsonStr string) string {507func mergeConditionals(jsonStr string) string {
317 pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})508 pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
318 var paths []string509 var paths []string
@@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string { · 48 unmodified lines
367 parentPath := trimSuffix(p, ".allOf")558 parentPath := trimSuffix(p, ".allOf")
368 559
369 for _, item := range allOf.Array() {560 for _, item := range allOf.Array() {
370 if props := item.Get("properties"); props.IsObject() {561 if !item.IsObject() {
371 props.ForEach(func(key, value gjson.Result) bool {562 continue
372 destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))563 }
373 updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))564 item.ForEach(func(key, value gjson.Result) bool {
374 jsonStr = string(updated)565 field := key.String()
375 return true566 switch field {
376 })567 case "required":
377 }568 if !value.IsArray() {
378 if req := item.Get("required"); req.IsArray() {569 return true
379 reqPath := joinPath(parentPath, "required")570 }
380 current := getStrings(jsonStr, reqPath)571 reqPath := joinPath(parentPath, "required")
381 for _, r := range req.Array() {572 current := getStrings(jsonStr, reqPath)
382 if s := r.String(); !contains(current, s) {573 for _, required := range value.Array() {
383 current = append(current, s)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)586 return true
387 jsonStr = string(updated)587 })
388 }
389 }588 }
390 jsonStr, _ = sjson.Delete(jsonStr, p)589 jsonStr, _ = sjson.Delete(jsonStr, p)
391 }590 }
392 return jsonStr591 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.
597func 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
395func flattenAnyOfOneOf(jsonStr string) string {614func flattenAnyOfOneOf(jsonStr string) string {
396 for _, key := range []string{"anyOf", "oneOf"} {615 for _, key := range []string{"anyOf", "oneOf"} {
397 paths := findPaths(jsonStr, key)616 paths := findPaths(jsonStr, key)
@@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string { · 11 unmodified lines
409 items := arr.Array()628 items := arr.Array()
410 bestIdx, allTypes := selectBest(items)629 bestIdx, allTypes := selectBest(items)
411 selected := items[bestIdx].Raw630 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 if parentDesc != "" {643 if parentDesc != "" {
414 selected = mergeDescriptionRaw(selected, parentDesc)644 selected = mergeDescriptionRaw(selected, parentDesc)
@@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) { · 37 unmodified lines
452 return682 return
453}683}
454 684
455func flattenTypeArrays(jsonStr string) string {685func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
456 paths := findPaths(jsonStr, "type")686 paths := findPaths(jsonStr, "type")
457 sortByDepth(paths)687 sortByDepth(paths)
458 688
@@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string { · 31 unmodified lines
490 }720 }
491 721
492 if hasNull {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 parts := splitGJSONPath(p)730 parts := splitGJSONPath(p)
494 if len(parts) >= 3 && parts[len(parts)-3] == "properties" {731 if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
495 fieldNameEscaped := parts[len(parts)-2]732 fieldNameEscaped := parts[len(parts)-2]
496 fieldName := unescapeGJSONPathKey(fieldNameEscaped)733 fieldName := unescapeGJSONPathKey(fieldNameEscaped)
497 objectPath := strings.Join(parts[:len(parts)-3], ".")734 objectPath := strings.Join(parts[:len(parts)-3], ".")
498 nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)735 nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
499 736 jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
500 propPath := joinPath(objectPath, "properties."+fieldNameEscaped)
501 jsonStr = appendHint(jsonStr, propPath, "(nullable)")
502 }737 }
503 }738 }
504 }739 }
@@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string { · 6 unmodified lines
511 }746 }
512 747
513 var filtered []string748 var filtered []string
514 for _, r := range req.Array() {749 for _, required := range req.Array() {
515 if !contains(fields, r.String()) {750 if !contains(fields, required.String()) {
516 filtered = append(filtered, r.String())751 filtered = append(filtered, required.String())
517 }752 }
518 }753 }
519
520 if len(filtered) == 0 {754 if len(filtered) == 0 {
521 jsonStr, _ = sjson.Delete(jsonStr, reqPath)755 jsonStr, _ = sjson.Delete(jsonStr, reqPath)
522 } else {756 } else {
@@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string { · 5 unmodified lines
528}762}
529 763
530func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {764func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
531 keywords := append(unsupportedConstraints,765 keywords := append(constraintKeywords(options),
532 "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",766 "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
533 "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords767 "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
534 "if", "then", "else",768 "if", "then", "else",
535 "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini769 "$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 deletePaths := make([]string, 0)775 deletePaths := make([]string, 0)
539 pathsByField := findPathsByFields(jsonStr, keywords)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
748func sortByDepth(paths []string) {985func 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
752func trimSuffix(path, suffix string) string {991func trimSuffix(path, suffix string) string {
internal/util/gemini_schema_test.go+154−72
@@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { · 24 unmodified lines
25 "properties": {25 "properties": {
26 "kind": {26 "kind": {
27 "type": "string",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 "properties": {53 "properties": {
54 "name": {54 "name": {
55 "type": "string",55 "type": "string",
56 "nullable": true,
56 "description": "(nullable)"57 "description": "(nullable)"
57 },58 },
58 "other": {59 "other": {
59 "type": "string"60 "type": "string"
60 }61 }
61 },62 },
62 "required": ["other"]63 "required": ["name", "other"]
63 }`64 }`
64 65
65 result := CleanJSONSchemaForAntigravity(input)66 result := CleanJSONSchemaForAntigravity(input)
@@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing · 59 unmodified lines
125 "properties": {126 "properties": {
126 "query": {127 "query": {
127 "type": "object",128 "type": "object",
129 "nullable": true,
128 "description": "Accepts: null | object",130 "description": "Accepts: null | object",
129 "properties": {131 "properties": {
130 "_": { "type": "boolean" },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 added219 // 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 expected := `{221 expected := `{
219 "type": "object",222 "type": "object",
220 "properties": {223 "properties": {
221 "customer": {224 "customer": {
222 "type": "object",225 "type": "object",
223 "description": "See: User",
224 "properties": {226 "properties": {
225 "reason": {227 "name": { "type": "string" },
226 "type": "string",228 "_": { "type": "boolean" }
227 "description": "Brief explanation of why you are calling this tool"
228 }
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 expected := `{258 expected := `{
260 "type": "object",259 "type": "object",
261 "properties": {260 "properties": {
262 "customer": {261 "customer": {
263 "type": "object",262 "type": "object",
264 "description": "He said \"hi\"\\nsecond line (See: User)",263 "description": "He said \"hi\"\\nsecond line",
265 "properties": {264 "properties": {
266 "reason": {265 "name": { "type": "string" },
267 "type": "string",266 "_": { "type": "boolean" }
268 "description": "Brief explanation of why you are calling this tool"
269 }
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 t.Errorf("Expected type: object, got: %v", resMap["type"])296 t.Errorf("Expected type: object, got: %v", resMap["type"])
300 }297 }
301 298
302 desc, ok := resMap["description"].(string)299 child := gjson.Get(result, "properties.child")
303 if !ok || !strings.Contains(desc, "Node") {300 if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") {
304 t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"])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 "properties": {496 "properties": {
500 "my.param": {497 "my.param": {
501 "type": "string",498 "type": "string",
499 "nullable": true,
502 "description": "(nullable)"500 "description": "(nullable)"
503 },501 },
504 "other": {502 "other": {
505 "type": "string"503 "type": "string"
506 }504 }
507 },505 },
508 "required": ["other"]506 "required": ["my.param", "other"]
509 }`507 }`
510 508
511 result := CleanJSONSchemaForAntigravity(input)509 result := CleanJSONSchemaForAntigravity(input)
@@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t · 66 unmodified lines
578 compareJSON(t, expected, result)576 compareJSON(t, expected, result)
579}577}
580 578
581func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {579func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
582 input := `{580 input := `{
583 "type": "object",581 "type": "object",
584 "properties": {582 "properties": {
@@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { · 6 unmodified lines
591 589
592 result := CleanJSONSchemaForAntigravity(input)590 result := CleanJSONSchemaForAntigravity(input)
593 591
594 if strings.Contains(result, "Allowed:") {592 if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() {
595 t.Errorf("Single value enum should not add Allowed hint, got: %s", result)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
767func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {765func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) {
768 input := `{766 input := `{
769 "type":"object",767 "type":"object",
770 "properties":{768 "properties":{
@@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) { · 6 unmodified lines
777 }`775 }`
778 776
779 result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))777 result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
780 for _, testCase := range []struct {778 for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} {
781 path string779 if result.Get(path).Exists() {
782 wantTypes []string780 t.Errorf("ignored response union %s survived: %s", path, result.Raw)
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())
795 }781 }
796 if !reflect.DeepEqual(gotTypes, testCase.wantTypes) {782 }
797 t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw)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
981func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {973func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
982 // Gemini API requires enum values to be strings, not numbers
983 input := `{974 input := `{
984 "type": "object",975 "type": "object",
985 "properties": {976 "properties": {
@@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { · 6 unmodified lines
992 result := CleanJSONSchemaForAntigravity(input)983 result := CleanJSONSchemaForAntigravity(input)
993 parsed := gjson.Parse(result)984 parsed := gjson.Parse(result)
994 985
995 // Tool enum schemas require both string values and a string type.986 // Antigravity ignores function-argument enum but still uses the declared type to choose the
996 for _, path := range []string{"properties.priority", "properties.level"} {987 // emitted JSON type. Preserve types and convert enum values to advisory hints.
997 if gotType := parsed.Get(path + ".type").String(); gotType != "string" {988 for path, wantType := range map[string]string{
998 t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result)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
1019func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {1002func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) {
1020 // Boolean enum values should also be converted to strings
1021 input := `{1003 input := `{
1022 "type": "object",1004 "type": "object",
1023 "properties": {1005 "properties": {
@@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { · 3 unmodified lines
1027 1009
1028 result := CleanJSONSchemaForAntigravity(input)1010 result := CleanJSONSchemaForAntigravity(input)
1029 1011
1030 // Boolean enum values should be converted to strings1012 value := gjson.Get(result, "properties.enabled")
1031 if strings.Contains(result, `"enum":[true,false]`) {1013 if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") {
1032 t.Errorf("Boolean enum values should be converted to strings, got: %s", result)1014 t.Errorf("Boolean tool enum should become a typed hint, 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)
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
1432func 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
1462func 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
1480func 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
1492func 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
1504func 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
1513func 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
1527func 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}