mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
Merge pull request #5008 from shengyy/fix/antigravity-schema-semantics
fix(antigravity): preserve response and tool schema semantics
This commit is contained in:
@@ -205,9 +205,8 @@ func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySche
|
||||
payloadStr = renamed
|
||||
}
|
||||
|
||||
toolSchemaCleaner := util.CleanJSONSchemaForGemini
|
||||
if useAntigravitySchema {
|
||||
toolSchemaCleaner = util.CleanJSONSchemaForAntigravity
|
||||
toolSchemaCleaner := func(schema string) string {
|
||||
return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
|
||||
}
|
||||
cleanNestedToolSchema := func(schemaRaw string) string {
|
||||
return cleanNestedSchema(toolSchemaCleaner, schemaRaw)
|
||||
|
||||
@@ -222,10 +222,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing
|
||||
for _, useAntigravitySchema := range []bool{false, true} {
|
||||
for name, schema := range shapes {
|
||||
doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`
|
||||
whole := util.CleanJSONSchemaForGemini(doc)
|
||||
if useAntigravitySchema {
|
||||
whole = util.CleanJSONSchemaForAntigravity(doc)
|
||||
}
|
||||
whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema)
|
||||
want := gjson.Get(whole, schemaPath).Raw
|
||||
got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
|
||||
if want != got {
|
||||
@@ -274,7 +271,7 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *testing.T) {
|
||||
func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t *testing.T) {
|
||||
payload := `{"request":{
|
||||
"tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
|
||||
"choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
|
||||
@@ -291,9 +288,9 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t
|
||||
|
||||
got := sanitizeAntigravityRequestSchemas(payload, true)
|
||||
responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
|
||||
union := responseSchema.Get("properties.action.anyOf")
|
||||
if !union.IsArray() || len(union.Array()) != 2 || union.Get("1.type").String() != "null" {
|
||||
t.Errorf("response anyOf union was flattened: %s", responseSchema.Raw)
|
||||
action := responseSchema.Get("properties.action")
|
||||
if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() {
|
||||
t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw)
|
||||
}
|
||||
conviction := responseSchema.Get("properties.conviction")
|
||||
if gotType := conviction.Get("type").String(); gotType != "number" {
|
||||
@@ -309,8 +306,35 @@ func TestSanitizeAntigravityRequestSchemasPreservesResponseUnionAndEnumType(t *t
|
||||
if toolSchema.Get("properties.choice.anyOf").Exists() {
|
||||
t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)
|
||||
}
|
||||
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "string" {
|
||||
t.Errorf("tool enum type = %q, want string: %s", gotType, toolSchema.Raw)
|
||||
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
|
||||
t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *testing.T) {
|
||||
payload := `{"request":{"tools":[{"functionDeclarations":[{"name":"tool","parameters":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"level":{"type":"number","enum":[1,2]},
|
||||
"note":{"type":["string","null"]}
|
||||
},
|
||||
"required":["level","note"]
|
||||
}}]}]}}`
|
||||
|
||||
for _, requirePlaceholder := range []bool{false, true} {
|
||||
got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder)
|
||||
schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
|
||||
if schema.Get("properties.level.type").String() != "number" {
|
||||
t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw)
|
||||
}
|
||||
for _, member := range schema.Get("properties.level.enum").Array() {
|
||||
if member.Type != gjson.String {
|
||||
t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw)
|
||||
}
|
||||
}
|
||||
if !schema.Get("properties.note.nullable").Bool() || schema.Get("required.1").String() != "note" {
|
||||
t.Fatalf("placeholder=%v lost native nullable/required semantics: %s", requirePlaceholder, schema.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t
|
||||
|
||||
type jsonSchemaCleanOptions struct {
|
||||
addPlaceholder bool
|
||||
antigravitySemantics bool
|
||||
removeToolTitle bool
|
||||
removeGeminiMetadata bool
|
||||
flattenUnions bool
|
||||
forceEnumStringType bool
|
||||
dropAllEnums bool
|
||||
dropBooleanEnums bool
|
||||
preserveAdditionalPropertiesFalse bool
|
||||
}
|
||||
|
||||
@@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct {
|
||||
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||
// semantic information as description hints and adding placeholders required by VALIDATED mode.
|
||||
func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
||||
return CleanJSONSchemaForAntigravityTool(jsonStr, true)
|
||||
}
|
||||
|
||||
// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private
|
||||
// backend accepts enum members only as strings, but the declared type still controls the JSON
|
||||
// type of generated function arguments, so numeric and boolean types must not be rewritten.
|
||||
// requirePlaceholder is used only for Claude VALIDATED mode.
|
||||
func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
|
||||
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
|
||||
addPlaceholder: true,
|
||||
flattenUnions: true,
|
||||
forceEnumStringType: true,
|
||||
addPlaceholder: requirePlaceholder,
|
||||
antigravitySemantics: true,
|
||||
removeToolTitle: !requirePlaceholder,
|
||||
flattenUnions: true,
|
||||
dropAllEnums: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
||||
// compatibility rewrites that would alter the client's structured output contract.
|
||||
//
|
||||
// Sanitization policy:
|
||||
// - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf),
|
||||
// and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint).
|
||||
// - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems,
|
||||
// maxItems, uniqueItems, format, default, examples).
|
||||
// - Passthrough: type, properties, items, required, description, enum, nullable, and
|
||||
// additionalProperties: false (which Antigravity natively enforces for response schemas).
|
||||
// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
|
||||
// - Flattened: allOf merged into properties/required.
|
||||
// - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint),
|
||||
// $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties,
|
||||
// and x-* extensions.
|
||||
// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
|
||||
// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
|
||||
// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
|
||||
// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
|
||||
func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
|
||||
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
|
||||
antigravitySemantics: true,
|
||||
flattenUnions: true,
|
||||
dropBooleanEnums: true,
|
||||
preserveAdditionalPropertiesFalse: true,
|
||||
})
|
||||
}
|
||||
@@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string {
|
||||
// cleanJSONSchema performs the core cleaning operations on the JSON schema.
|
||||
func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
// Phase 1: Convert and add hints
|
||||
jsonStr = convertRefsToHints(jsonStr)
|
||||
if options.antigravitySemantics {
|
||||
jsonStr = inlineLocalRefs(jsonStr)
|
||||
}
|
||||
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
|
||||
jsonStr = convertConstToEnum(jsonStr)
|
||||
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
|
||||
jsonStr = addEnumHints(jsonStr)
|
||||
jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
|
||||
if !options.preserveAdditionalPropertiesFalse {
|
||||
jsonStr = addAdditionalPropertiesHints(jsonStr)
|
||||
}
|
||||
jsonStr = moveConstraintsToDescription(jsonStr)
|
||||
jsonStr = moveConstraintsToDescription(jsonStr, options)
|
||||
if options.antigravitySemantics {
|
||||
jsonStr = moveNotToDescription(jsonStr)
|
||||
}
|
||||
|
||||
// Phase 2: Flatten complex structures
|
||||
jsonStr = mergeConditionals(jsonStr)
|
||||
@@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
if options.flattenUnions {
|
||||
jsonStr = flattenAnyOfOneOf(jsonStr)
|
||||
}
|
||||
jsonStr = flattenTypeArrays(jsonStr)
|
||||
jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
|
||||
|
||||
// Phase 3: Cleanup
|
||||
jsonStr = removeUnsupportedKeywords(jsonStr, options)
|
||||
@@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
// Gemini schema cleanup: remove nullable/title and placeholder-only fields.
|
||||
jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
|
||||
jsonStr = removePlaceholderFields(jsonStr)
|
||||
} else if options.removeToolTitle {
|
||||
// Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title.
|
||||
// Keep that harmless metadata policy without losing Antigravity's native nullable support.
|
||||
jsonStr = removeKeywords(jsonStr, []string{"title"})
|
||||
}
|
||||
jsonStr = cleanupRequiredFields(jsonStr)
|
||||
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||
@@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// convertRefsToHints converts $ref to description hints (Lazy Hint strategy).
|
||||
func convertRefsToHints(jsonStr string) string {
|
||||
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
|
||||
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
|
||||
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
|
||||
func inlineLocalRefs(jsonStr string) string {
|
||||
if !strings.Contains(jsonStr, `"$ref"`) {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(strings.NewReader(jsonStr))
|
||||
decoder.UseNumber()
|
||||
var root any
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
resolved := resolveLocalRefs(root, root, make(map[string]bool))
|
||||
out, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
return jsonStr
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func resolveLocalRefs(root, value any, active map[string]bool) any {
|
||||
switch node := value.(type) {
|
||||
case []any:
|
||||
out := make([]any, len(node))
|
||||
for i, item := range node {
|
||||
out[i] = resolveLocalRefs(root, item, active)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
ref, hasRef := node["$ref"].(string)
|
||||
if hasRef && strings.HasPrefix(ref, "#/") {
|
||||
if target, ok := resolveJSONPointer(root, ref); ok {
|
||||
if active[ref] {
|
||||
return cyclicRefFallback(node, target, ref)
|
||||
}
|
||||
active[ref] = true
|
||||
resolvedTarget := resolveLocalRefs(root, target, active)
|
||||
delete(active, ref)
|
||||
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
|
||||
out := make(map[string]any, len(targetMap)+len(node))
|
||||
for key, item := range targetMap {
|
||||
out[key] = item
|
||||
}
|
||||
for key, item := range node {
|
||||
if key == "$ref" {
|
||||
continue
|
||||
}
|
||||
out[key] = resolveLocalRefs(root, item, active)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(node))
|
||||
for key, item := range node {
|
||||
out[key] = resolveLocalRefs(root, item, active)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func resolveJSONPointer(root any, ref string) (any, bool) {
|
||||
current := root
|
||||
for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
|
||||
part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~")
|
||||
switch node := current.(type) {
|
||||
case map[string]any:
|
||||
var ok bool
|
||||
current, ok = node[part]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
case []any:
|
||||
index, err := strconv.Atoi(part)
|
||||
if err != nil || index < 0 || index >= len(node) {
|
||||
return nil, false
|
||||
}
|
||||
current = node[index]
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any {
|
||||
out := make(map[string]any, len(node)+2)
|
||||
if targetMap, ok := target.(map[string]any); ok {
|
||||
for _, key := range []string{"type", "nullable", "description"} {
|
||||
if value, exists := targetMap[key]; exists {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, value := range node {
|
||||
if key != "$ref" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
name := refName(ref)
|
||||
hint := "See: " + name
|
||||
if description, _ := out["description"].(string); description != "" {
|
||||
out["description"] = mergeHint(description, hint)
|
||||
} else {
|
||||
out["description"] = hint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func refName(ref string) string {
|
||||
if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~")
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// convertRefsToHints retains sibling keywords and converts only unresolved or external references
|
||||
// to descriptions. Local references have already been expanded by inlineLocalRefs.
|
||||
func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
|
||||
paths := findPaths(jsonStr, "$ref")
|
||||
sortByDepth(paths)
|
||||
|
||||
for _, p := range paths {
|
||||
refVal := gjson.Get(jsonStr, p).String()
|
||||
defName := refVal
|
||||
if idx := strings.LastIndex(refVal, "/"); idx >= 0 {
|
||||
defName = refVal[idx+1:]
|
||||
}
|
||||
defName := refName(refVal)
|
||||
|
||||
parentPath := trimSuffix(p, ".$ref")
|
||||
hint := fmt.Sprintf("See: %s", defName)
|
||||
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
|
||||
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||
if !preserveSiblings {
|
||||
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
|
||||
hint = fmt.Sprintf("%s (%s)", existing, hint)
|
||||
}
|
||||
replacement := `{"type":"object","description":""}`
|
||||
replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
|
||||
jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes))
|
||||
continue
|
||||
}
|
||||
|
||||
replacement := `{"type":"object","description":""}`
|
||||
replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
|
||||
replacement = string(replacementBytes)
|
||||
jsonStr = setRawAt(jsonStr, parentPath, replacement)
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
jsonStr = appendHint(jsonStr, parentPath, hint)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
@@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string {
|
||||
}
|
||||
|
||||
// convertEnumValuesToStrings ensures all enum values use the string representation required by
|
||||
// Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve
|
||||
// their declared type because the upstream decoder uses it to select the emitted JSON value type.
|
||||
// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
|
||||
// emitted JSON type on both response and function-argument paths.
|
||||
func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
|
||||
for _, p := range findPaths(jsonStr, "enum") {
|
||||
arr := gjson.Get(jsonStr, p)
|
||||
@@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// Antigravity does not enforce enum on function arguments and ignores boolean response enums.
|
||||
// Preserve the advisory values in description, but do not leave an unenforced constraint in the
|
||||
// schema contract. Response enums for string, number, and integer remain native constraints.
|
||||
func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
for _, path := range findPaths(jsonStr, "enum") {
|
||||
parentPath := trimSuffix(path, ".enum")
|
||||
shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean")
|
||||
if !shouldDrop {
|
||||
continue
|
||||
}
|
||||
enum := gjson.Get(jsonStr, path)
|
||||
if enum.IsArray() && len(enum.Array()) == 1 {
|
||||
jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String())
|
||||
}
|
||||
jsonStr, _ = sjson.Delete(jsonStr, path)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func addAdditionalPropertiesHints(jsonStr string) string {
|
||||
for _, p := range findPaths(jsonStr, "additionalProperties") {
|
||||
if gjson.Get(jsonStr, p).Type == gjson.False {
|
||||
@@ -295,9 +466,18 @@ var unsupportedConstraints = []string{
|
||||
"default", "examples", // Claude rejects these in VALIDATED mode
|
||||
}
|
||||
|
||||
func moveConstraintsToDescription(jsonStr string) string {
|
||||
pathsByField := findPathsByFields(jsonStr, unsupportedConstraints)
|
||||
for _, key := range unsupportedConstraints {
|
||||
func constraintKeywords(options jsonSchemaCleanOptions) []string {
|
||||
keywords := append([]string(nil), unsupportedConstraints...)
|
||||
if options.antigravitySemantics {
|
||||
keywords = append(keywords, "minimum", "maximum", "multipleOf")
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
constraints := constraintKeywords(options)
|
||||
pathsByField := findPathsByFields(jsonStr, constraints)
|
||||
for _, key := range constraints {
|
||||
for _, p := range pathsByField[key] {
|
||||
val := gjson.Get(jsonStr, p)
|
||||
if !val.Exists() || val.IsObject() || val.IsArray() {
|
||||
@@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func moveNotToDescription(jsonStr string) string {
|
||||
for _, path := range findPaths(jsonStr, "not") {
|
||||
value := gjson.Get(jsonStr, path)
|
||||
if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
|
||||
continue
|
||||
}
|
||||
jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func mergeConditionals(jsonStr string) string {
|
||||
pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
|
||||
var paths []string
|
||||
@@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string {
|
||||
parentPath := trimSuffix(p, ".allOf")
|
||||
|
||||
for _, item := range allOf.Array() {
|
||||
if props := item.Get("properties"); props.IsObject() {
|
||||
props.ForEach(func(key, value gjson.Result) bool {
|
||||
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
|
||||
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))
|
||||
jsonStr = string(updated)
|
||||
return true
|
||||
})
|
||||
if !item.IsObject() {
|
||||
continue
|
||||
}
|
||||
if req := item.Get("required"); req.IsArray() {
|
||||
reqPath := joinPath(parentPath, "required")
|
||||
current := getStrings(jsonStr, reqPath)
|
||||
for _, r := range req.Array() {
|
||||
if s := r.String(); !contains(current, s) {
|
||||
current = append(current, s)
|
||||
item.ForEach(func(key, value gjson.Result) bool {
|
||||
field := key.String()
|
||||
switch field {
|
||||
case "required":
|
||||
if !value.IsArray() {
|
||||
return true
|
||||
}
|
||||
reqPath := joinPath(parentPath, "required")
|
||||
current := getStrings(jsonStr, reqPath)
|
||||
for _, required := range value.Array() {
|
||||
if name := required.String(); !contains(current, name) {
|
||||
current = append(current, name)
|
||||
}
|
||||
}
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
|
||||
jsonStr = string(updated)
|
||||
case "if", "then", "else", "allOf":
|
||||
// Conditional applicability cannot be represented by the upstream schema.
|
||||
default:
|
||||
destination := joinPath(parentPath, escapeGJSONPathKey(field))
|
||||
jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value)
|
||||
}
|
||||
updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
|
||||
jsonStr = string(updated)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||
}
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing
|
||||
// definition. A parent schema is the canonical definition; allOf and conditional branches may
|
||||
// enrich gaps in it, but can never replace it with a narrower branch shell.
|
||||
func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string {
|
||||
existing := gjson.Get(jsonStr, destination)
|
||||
if !existing.Exists() {
|
||||
updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw))
|
||||
return string(updated)
|
||||
}
|
||||
if !existing.IsObject() || !incoming.IsObject() {
|
||||
return jsonStr
|
||||
}
|
||||
incoming.ForEach(func(key, value gjson.Result) bool {
|
||||
child := joinPath(destination, escapeGJSONPathKey(key.String()))
|
||||
jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value)
|
||||
return true
|
||||
})
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func flattenAnyOfOneOf(jsonStr string) string {
|
||||
for _, key := range []string{"anyOf", "oneOf"} {
|
||||
paths := findPaths(jsonStr, key)
|
||||
@@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string {
|
||||
items := arr.Array()
|
||||
bestIdx, allTypes := selectBest(items)
|
||||
selected := items[bestIdx].Raw
|
||||
hasNull := false
|
||||
for _, item := range items {
|
||||
if item.Get("type").String() == "null" {
|
||||
hasNull = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasNull && items[bestIdx].Get("type").String() != "null" {
|
||||
updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
|
||||
selected = string(updated)
|
||||
}
|
||||
|
||||
if parentDesc != "" {
|
||||
selected = mergeDescriptionRaw(selected, parentDesc)
|
||||
@@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) {
|
||||
return
|
||||
}
|
||||
|
||||
func flattenTypeArrays(jsonStr string) string {
|
||||
func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
|
||||
paths := findPaths(jsonStr, "type")
|
||||
sortByDepth(paths)
|
||||
|
||||
@@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string {
|
||||
}
|
||||
|
||||
if hasNull {
|
||||
if preserveNativeNullable {
|
||||
updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
|
||||
jsonStr = string(updated)
|
||||
jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
|
||||
continue
|
||||
}
|
||||
|
||||
parts := splitGJSONPath(p)
|
||||
if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
|
||||
fieldNameEscaped := parts[len(parts)-2]
|
||||
fieldName := unescapeGJSONPathKey(fieldNameEscaped)
|
||||
objectPath := strings.Join(parts[:len(parts)-3], ".")
|
||||
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
|
||||
|
||||
propPath := joinPath(objectPath, "properties."+fieldNameEscaped)
|
||||
jsonStr = appendHint(jsonStr, propPath, "(nullable)")
|
||||
jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string {
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, r := range req.Array() {
|
||||
if !contains(fields, r.String()) {
|
||||
filtered = append(filtered, r.String())
|
||||
for _, required := range req.Array() {
|
||||
if !contains(fields, required.String()) {
|
||||
filtered = append(filtered, required.String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
jsonStr, _ = sjson.Delete(jsonStr, reqPath)
|
||||
} else {
|
||||
@@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string {
|
||||
}
|
||||
|
||||
func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
|
||||
keywords := append(unsupportedConstraints,
|
||||
keywords := append(constraintKeywords(options),
|
||||
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
|
||||
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
|
||||
"if", "then", "else",
|
||||
"$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini
|
||||
)
|
||||
if options.antigravitySemantics {
|
||||
keywords = append(keywords, "not")
|
||||
}
|
||||
|
||||
deletePaths := make([]string, 0)
|
||||
pathsByField := findPathsByFields(jsonStr, keywords)
|
||||
@@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
|
||||
}
|
||||
|
||||
func sortByDepth(paths []string) {
|
||||
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
|
||||
sort.SliceStable(paths, func(i, j int) bool {
|
||||
return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j]))
|
||||
})
|
||||
}
|
||||
|
||||
func trimSuffix(path, suffix string) string {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["InsightVizNode"]
|
||||
"description": "Allowed: InsightVizNode"
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -53,13 +53,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "(nullable)"
|
||||
},
|
||||
"other": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["other"]
|
||||
"required": ["name", "other"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
@@ -125,6 +126,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"description": "Accepts: null | object",
|
||||
"properties": {
|
||||
"_": { "type": "boolean" },
|
||||
@@ -214,20 +216,18 @@ func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
// After $ref is converted to placeholder object, empty schema placeholder is also added
|
||||
// The local reference is expanded before definitions are removed. Claude VALIDATED mode adds
|
||||
// only its optional-object placeholder; the referenced property definition remains intact.
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {
|
||||
"type": "object",
|
||||
"description": "See: User",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you are calling this tool"
|
||||
}
|
||||
"name": { "type": "string" },
|
||||
"_": { "type": "boolean" }
|
||||
},
|
||||
"required": ["reason"]
|
||||
"required": ["_"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -255,20 +255,17 @@ func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testin
|
||||
}
|
||||
}`
|
||||
|
||||
// After $ref is converted, empty schema placeholder is also added
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {
|
||||
"type": "object",
|
||||
"description": "He said \"hi\"\\nsecond line (See: User)",
|
||||
"description": "He said \"hi\"\\nsecond line",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you are calling this tool"
|
||||
}
|
||||
"name": { "type": "string" },
|
||||
"_": { "type": "boolean" }
|
||||
},
|
||||
"required": ["reason"]
|
||||
"required": ["_"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -299,9 +296,9 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
|
||||
t.Errorf("Expected type: object, got: %v", resMap["type"])
|
||||
}
|
||||
|
||||
desc, ok := resMap["description"].(string)
|
||||
if !ok || !strings.Contains(desc, "Node") {
|
||||
t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"])
|
||||
child := gjson.Get(result, "properties.child")
|
||||
if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") {
|
||||
t.Errorf("Expected typed cycle hint containing Node, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,13 +496,14 @@ func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing
|
||||
"properties": {
|
||||
"my.param": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "(nullable)"
|
||||
},
|
||||
"other": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["other"]
|
||||
"required": ["my.param", "other"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
@@ -578,7 +576,7 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *t
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -591,8 +589,8 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if strings.Contains(result, "Allowed:") {
|
||||
t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
|
||||
if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() {
|
||||
t.Errorf("Ignored tool enum should become a hint, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +762,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) {
|
||||
input := `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
@@ -777,24 +775,18 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesUnions(t *testing.T) {
|
||||
}`
|
||||
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
for _, testCase := range []struct {
|
||||
path string
|
||||
wantTypes []string
|
||||
}{
|
||||
{path: "properties.action.anyOf", wantTypes: []string{"object", "null"}},
|
||||
{path: "properties.label.oneOf", wantTypes: []string{"string", "null"}},
|
||||
for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} {
|
||||
if result.Get(path).Exists() {
|
||||
t.Errorf("ignored response union %s survived: %s", path, result.Raw)
|
||||
}
|
||||
}
|
||||
for _, testCase := range []struct{ path, wantType string }{
|
||||
{path: "properties.action", wantType: "object"},
|
||||
{path: "properties.label", wantType: "string"},
|
||||
} {
|
||||
union := result.Get(testCase.path)
|
||||
if !union.IsArray() {
|
||||
t.Errorf("response union %s was flattened: %s", testCase.path, result.Raw)
|
||||
continue
|
||||
}
|
||||
var gotTypes []string
|
||||
for _, branch := range union.Array() {
|
||||
gotTypes = append(gotTypes, branch.Get("type").String())
|
||||
}
|
||||
if !reflect.DeepEqual(gotTypes, testCase.wantTypes) {
|
||||
t.Errorf("response union %s types = %v, want %v: %s", testCase.path, gotTypes, testCase.wantTypes, result.Raw)
|
||||
schema := result.Get(testCase.path)
|
||||
if schema.Get("type").String() != testCase.wantType || !schema.Get("nullable").Bool() {
|
||||
t.Errorf("%s was not projected to nullable %s: %s", testCase.path, testCase.wantType, result.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,8 +970,7 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {
|
||||
// Gemini API requires enum values to be strings, not numbers
|
||||
func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -992,32 +983,23 @@ func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) {
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
parsed := gjson.Parse(result)
|
||||
|
||||
// Tool enum schemas require both string values and a string type.
|
||||
for _, path := range []string{"properties.priority", "properties.level"} {
|
||||
if gotType := parsed.Get(path + ".type").String(); gotType != "string" {
|
||||
t.Errorf("Tool enum type at %s = %q, want string: %s", path, gotType, result)
|
||||
// Antigravity ignores function-argument enum but still uses the declared type to choose the
|
||||
// emitted JSON type. Preserve types and convert enum values to advisory hints.
|
||||
for path, wantType := range map[string]string{
|
||||
"properties.priority": "integer",
|
||||
"properties.level": "number",
|
||||
"properties.status": "string",
|
||||
} {
|
||||
if gotType := parsed.Get(path + ".type").String(); gotType != wantType {
|
||||
t.Errorf("Tool enum type at %s = %q, want %s: %s", path, gotType, wantType, result)
|
||||
}
|
||||
if parsed.Get(path+".enum").Exists() || !strings.Contains(parsed.Get(path+".description").String(), "Allowed:") {
|
||||
t.Errorf("Tool enum at %s was not projected to a hint: %s", path, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric enum values should be converted to strings
|
||||
if strings.Contains(result, `"enum":[0,1,2]`) {
|
||||
t.Errorf("Integer enum values should be converted to strings, got: %s", result)
|
||||
}
|
||||
if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) {
|
||||
t.Errorf("Float enum values should be converted to strings, got: %s", result)
|
||||
}
|
||||
// Should contain string versions
|
||||
if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) {
|
||||
t.Errorf("Integer enum values should be converted to string format, got: %s", result)
|
||||
}
|
||||
// String enum values should remain unchanged
|
||||
if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) {
|
||||
t.Errorf("String enum values should remain unchanged, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
|
||||
// Boolean enum values should also be converted to strings
|
||||
func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1027,13 +1009,9 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Boolean enum values should be converted to strings
|
||||
if strings.Contains(result, `"enum":[true,false]`) {
|
||||
t.Errorf("Boolean enum values should be converted to strings, got: %s", result)
|
||||
}
|
||||
// Should contain string versions "true" and "false"
|
||||
if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) {
|
||||
t.Errorf("Boolean enum values should be converted to string format, got: %s", result)
|
||||
value := gjson.Get(result, "properties.enabled")
|
||||
if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") {
|
||||
t.Errorf("Boolean tool enum should become a typed hint, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,3 +1428,107 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t *testing.T) {
|
||||
input := `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"kind":{"type":"string"},
|
||||
"action":{"type":"object","properties":{"full":{"type":"string"}},"required":["full"]}
|
||||
},
|
||||
"required":["kind","action"],
|
||||
"allOf":[{
|
||||
"if":{"properties":{"kind":{"const":"skip"}}},
|
||||
"then":{"properties":{
|
||||
"action":{"type":"null"},
|
||||
"branch_only":{"type":"integer"}
|
||||
}}
|
||||
}]
|
||||
}`
|
||||
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
action := result.Get("properties.action")
|
||||
if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() {
|
||||
t.Fatalf("conditional branch replaced canonical action: %s", result.Raw)
|
||||
}
|
||||
if action.Get("required.0").String() != "full" || !result.Get("properties.branch_only").Exists() {
|
||||
t.Fatalf("conditional merge lost parent or branch-only information: %s", result.Raw)
|
||||
}
|
||||
if result.Get("allOf").Exists() || strings.Contains(result.Raw, `"if"`) || strings.Contains(result.Raw, `"then"`) {
|
||||
t.Fatalf("unsupported conditional keywords survived: %s", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) {
|
||||
input := `{
|
||||
"$defs":{"Payload":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}},
|
||||
"type":"object",
|
||||
"properties":{"payload":{"$ref":"#/$defs/Payload"}},
|
||||
"required":["payload"]
|
||||
}`
|
||||
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) {
|
||||
t.Fatalf("local reference metadata survived: %s", result.Raw)
|
||||
}
|
||||
payload := result.Get("properties.payload")
|
||||
if payload.Get("type").String() != "object" || payload.Get("properties.id.type").String() != "integer" || payload.Get("required.0").String() != "id" {
|
||||
t.Fatalf("local reference definition was not inlined: %s", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}`
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
value := result.Get("properties.value")
|
||||
if value.Get("type").String() != "number" || !value.Get("nullable").Bool() {
|
||||
t.Fatalf("type array was not projected to native nullable: %s", result.Raw)
|
||||
}
|
||||
if result.Get("required.0").String() != "value" {
|
||||
t.Fatalf("nullable required property became optional: %s", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}`
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityTool(input, false))
|
||||
value := result.Get("properties.value")
|
||||
if value.Get("type").String() != "number" {
|
||||
t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw)
|
||||
}
|
||||
if value.Get("enum").Exists() || !strings.Contains(value.Get("description").String(), "Allowed: 1, 2") {
|
||||
t.Fatalf("ignored tool enum was not projected to a hint: %s", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}`
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
value := result.Get("properties.value")
|
||||
if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") {
|
||||
t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) {
|
||||
input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}`
|
||||
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
|
||||
value := result.Get("properties.value")
|
||||
for _, keyword := range []string{"minimum", "maximum", "not"} {
|
||||
if value.Get(keyword).Exists() {
|
||||
t.Fatalf("ignored constraint %s survived: %s", keyword, result.Raw)
|
||||
}
|
||||
if !strings.Contains(value.Get("description").String(), keyword+":") {
|
||||
t.Fatalf("ignored constraint %s lost its hint: %s", keyword, result.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByDepthUsesSegmentsAndIsStable(t *testing.T) {
|
||||
paths := []string{"root.verylong", "root.x.y", "first.same", "later.same"}
|
||||
sortByDepth(paths)
|
||||
want := []string{"root.x.y", "root.verylong", "first.same", "later.same"}
|
||||
if !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("sortByDepth() = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user