vibe-proxy/backend/internal/util/gemini_schema_test.go
2026-08-24 00:10:41 +02:00

2234 lines
69 KiB
Go

package util
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
input := `{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "InsightVizNode"
}
}
}`
expected := `{
"type": "object",
"properties": {
"kind": {
"type": "string",
"description": "Allowed: InsightVizNode"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {
input := `{
"type": "object",
"properties": {
"name": {
"type": ["string", "null"]
},
"other": {
"type": "string"
}
},
"required": ["name", "other"]
}`
expected := `{
"type": "object",
"properties": {
"name": {
"type": "string",
"nullable": true,
"description": "(nullable)"
},
"other": {
"type": "string"
}
},
"required": ["name", "other"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) {
input := `{
"type": "object",
"properties": {
"tags": {
"type": "array",
"description": "List of tags",
"minItems": 1
},
"name": {
"type": "string",
"description": "User name",
"minLength": 3
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
// minItems should be REMOVED and moved to description
if strings.Contains(result, `"minItems"`) {
t.Errorf("minItems keyword should be removed")
}
if !strings.Contains(result, "minItems: 1") {
t.Errorf("minItems hint missing in description")
}
// minLength should be moved to description
if !strings.Contains(result, "minLength: 3") {
t.Errorf("minLength hint missing in description")
}
if strings.Contains(result, `"minLength":`) || strings.Contains(result, `"minLength" :`) {
t.Errorf("minLength keyword should be removed")
}
}
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing.T) {
input := `{
"type": "object",
"properties": {
"query": {
"anyOf": [
{ "type": "null" },
{
"type": "object",
"properties": {
"kind": { "type": "string" }
}
}
]
}
}
}`
expected := `{
"type": "object",
"properties": {
"query": {
"type": "object",
"nullable": true,
"description": "Accepts: null | object",
"properties": {
"_": { "type": "boolean" },
"kind": { "type": "string" }
},
"required": ["_"]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {
input := `{
"type": "object",
"properties": {
"config": {
"oneOf": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
}`
expected := `{
"type": "object",
"properties": {
"config": {
"type": "string",
"description": "Accepts: string | integer"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) {
input := `{
"type": "object",
"allOf": [
{
"properties": {
"a": { "type": "string" }
},
"required": ["a"]
},
{
"properties": {
"b": { "type": "integer" }
},
"required": ["b"]
}
]
}`
expected := `{
"type": "object",
"properties": {
"a": { "type": "string" },
"b": { "type": "integer" }
},
"required": ["a", "b"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {
input := `{
"definitions": {
"User": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"customer": { "$ref": "#/definitions/User" }
}
}`
// 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",
"properties": {
"name": { "type": "string" },
"_": { "type": "boolean" }
},
"required": ["_"]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) {
input := `{
"definitions": {
"User": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"customer": {
"description": "He said \"hi\"\\nsecond line",
"$ref": "#/definitions/User"
}
}
}`
expected := `{
"type": "object",
"properties": {
"customer": {
"type": "object",
"description": "He said \"hi\"\\nsecond line",
"properties": {
"name": { "type": "string" },
"_": { "type": "boolean" }
},
"required": ["_"]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
input := `{
"definitions": {
"Node": {
"type": "object",
"properties": {
"child": { "$ref": "#/definitions/Node" }
}
}
},
"$ref": "#/definitions/Node"
}`
result := CleanJSONSchemaForAntigravity(input)
var resMap map[string]interface{}
json.Unmarshal([]byte(result), &resMap)
if resMap["type"] != "object" {
t.Errorf("Expected type: object, got: %v", resMap["type"])
}
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)
}
}
func TestCleanJSONSchemaForAntigravity_RequiredCleanup(t *testing.T) {
input := `{
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
},
"required": ["a", "b", "c"]
}`
expected := `{
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
},
"required": ["a", "b"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) {
input := `{
"type": "object",
"allOf": [
{
"properties": {
"my.param": { "type": "string" }
},
"required": ["my.param"]
},
{
"properties": {
"b": { "type": "integer" }
},
"required": ["b"]
}
]
}`
expected := `{
"type": "object",
"properties": {
"my.param": { "type": "string" },
"b": { "type": "integer" }
},
"required": ["my.param", "b"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_PropertyNameCollision(t *testing.T) {
// A tool has an argument named "pattern" - should NOT be treated as a constraint
input := `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The regex pattern"
}
},
"required": ["pattern"]
}`
expected := `{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The regex pattern"
}
},
"required": ["pattern"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
var resMap map[string]interface{}
json.Unmarshal([]byte(result), &resMap)
props, _ := resMap["properties"].(map[string]interface{})
if _, ok := props["description"]; ok {
t.Errorf("Invalid 'description' property injected into properties map")
}
}
func TestCleanJSONSchemaForAntigravity_DotKeys(t *testing.T) {
input := `{
"type": "object",
"properties": {
"my.param": {
"type": "string",
"$ref": "#/definitions/MyType"
}
},
"definitions": {
"MyType": { "type": "string" }
}
}`
result := CleanJSONSchemaForAntigravity(input)
var resMap map[string]interface{}
if err := json.Unmarshal([]byte(result), &resMap); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
props, ok := resMap["properties"].(map[string]interface{})
if !ok {
t.Fatalf("properties missing")
}
if val, ok := props["my.param"]; !ok {
t.Fatalf("Key 'my.param' is missing. Result: %s", result)
} else {
valMap, _ := val.(map[string]interface{})
if _, hasRef := valMap["$ref"]; hasRef {
t.Errorf("Key 'my.param' still contains $ref")
}
if _, ok := props["my"]; ok {
t.Errorf("Artifact key 'my' created by sjson splitting")
}
}
}
func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) {
input := `{
"type": "object",
"properties": {
"value": {
"anyOf": [
{ "type": "string" },
{ "type": "integer" },
{ "type": "null" }
]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
if !strings.Contains(result, "Accepts:") {
t.Errorf("Expected alternative types hint, got: %s", result)
}
if !strings.Contains(result, "string") || !strings.Contains(result, "integer") {
t.Errorf("Expected all alternative types in hint, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
"name": {
"type": ["string", "null"],
"description": "User name"
}
},
"required": ["name"]
}`
result := CleanJSONSchemaForAntigravity(input)
if !strings.Contains(result, "(nullable)") {
t.Errorf("Expected nullable hint, got: %s", result)
}
if !strings.Contains(result, "User name") {
t.Errorf("Expected original description to be preserved, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing.T) {
input := `{
"type": "object",
"properties": {
"my.param": {
"type": ["string", "null"]
},
"other": {
"type": "string"
}
},
"required": ["my.param", "other"]
}`
expected := `{
"type": "object",
"properties": {
"my.param": {
"type": "string",
"nullable": true,
"description": "(nullable)"
},
"other": {
"type": "string"
}
},
"required": ["my.param", "other"]
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "inactive", "pending"],
"description": "Current status"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
if !strings.Contains(result, "Allowed:") {
t.Errorf("Expected enum values hint, got: %s", result)
}
if !strings.Contains(result, "active") || !strings.Contains(result, "inactive") {
t.Errorf("Expected enum values in hint, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
"name": { "type": "string" }
},
"additionalProperties": false
}`
result := CleanJSONSchemaForAntigravity(input)
if !strings.Contains(result, "No extra properties allowed") {
t.Errorf("Expected additionalProperties hint, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {
input := `{
"type": "object",
"properties": {
"config": {
"description": "Parent desc",
"anyOf": [
{ "type": "string", "description": "Child desc" },
{ "type": "integer" }
]
}
}
}`
expected := `{
"type": "object",
"properties": {
"config": {
"type": "string",
"description": "Parent desc (Child desc) (Accepts: string | integer)"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["fixed"]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
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)
}
}
func TestCleanJSONSchemaForAntigravity_MultipleNonNullTypes(t *testing.T) {
input := `{
"type": "object",
"properties": {
"value": {
"type": ["string", "integer", "boolean"]
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
if !strings.Contains(result, "Accepts:") {
t.Errorf("Expected multiple types hint, got: %s", result)
}
if !strings.Contains(result, "string") || !strings.Contains(result, "integer") || !strings.Contains(result, "boolean") {
t.Errorf("Expected all types in hint, got: %s", result)
}
}
func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
var expMap, actMap map[string]interface{}
errExp := json.Unmarshal([]byte(expectedJSON), &expMap)
errAct := json.Unmarshal([]byte(actualJSON), &actMap)
if errExp != nil || errAct != nil {
t.Fatalf("JSON Unmarshal error. Exp: %v, Act: %v", errExp, errAct)
}
if !reflect.DeepEqual(expMap, actMap) {
expBytes, _ := json.MarshalIndent(expMap, "", " ")
actBytes, _ := json.MarshalIndent(actMap, "", " ")
t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes))
}
}
// ============================================================================
// Empty Schema Placeholder Tests
// ============================================================================
func TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) {
// Empty object schema with no properties should get a placeholder
input := `{
"type": "object"
}`
result := CleanJSONSchemaForAntigravity(input)
// Should have placeholder property added
if !strings.Contains(result, `"reason"`) {
t.Errorf("Empty schema should have 'reason' placeholder property, got: %s", result)
}
if !strings.Contains(result, `"required"`) {
t.Errorf("Empty schema should have 'required' with 'reason', got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_EmptyPropertiesPlaceholder(t *testing.T) {
// Object with empty properties object
input := `{
"type": "object",
"properties": {}
}`
result := CleanJSONSchemaForAntigravity(input)
// Should have placeholder property added
if !strings.Contains(result, `"reason"`) {
t.Errorf("Empty properties should have 'reason' placeholder, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_NonEmptySchemaUnchanged(t *testing.T) {
// Schema with properties should NOT get placeholder
input := `{
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}`
result := CleanJSONSchemaForAntigravity(input)
// Should NOT have placeholder property
if strings.Contains(result, `"reason"`) {
t.Errorf("Non-empty schema should NOT have 'reason' placeholder, got: %s", result)
}
// Original properties should be preserved
if !strings.Contains(result, `"name"`) {
t.Errorf("Original property 'name' should be preserved, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) {
// Nested empty object in items should also get placeholder
input := `{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object"
}
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
// Nested empty object should also get placeholder
// Check that the nested object has a reason property
parsed := gjson.Parse(result)
nestedProps := parsed.Get("properties.items.items.properties")
if !nestedProps.Exists() || !nestedProps.Get("reason").Exists() {
t.Errorf("Nested empty object should have 'reason' placeholder, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) {
// Empty schema with description should preserve description and add placeholder
input := `{
"type": "object",
"description": "An empty object"
}`
result := CleanJSONSchemaForAntigravity(input)
// Should have both description and placeholder
if !strings.Contains(result, `"An empty object"`) {
t.Errorf("Description should be preserved, got: %s", result)
}
if !strings.Contains(result, `"reason"`) {
t.Errorf("Empty schema should have 'reason' placeholder, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *testing.T) {
bare := gjson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`))
if bare.Get("properties.reason").Exists() || bare.Get("required").Exists() {
t.Fatalf("bare response schema gained tool placeholders: %s", bare.Raw)
}
input := `{
"type":"object",
"title":"Response",
"nullable":true,
"properties":{
"empty":{"type":"object"},
"optional":{"type":"object","properties":{"value":{"type":"string"}}}
}
}`
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, path := range []string{
"properties.empty.properties.reason",
"properties.empty.required",
"properties.optional.properties._",
"properties.optional.required",
} {
if result.Get(path).Exists() {
t.Errorf("response schema gained tool-only field %s: %s", path, result.Raw)
}
}
if result.Get("title").String() != "Response" || !result.Get("nullable").Bool() {
t.Errorf("Antigravity response metadata was removed: %s", result.Raw)
}
}
func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T) {
input := `{
"type":"object",
"properties":{
"action":{"anyOf":[
{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]},
{"type":"null"}
]},
"label":{"oneOf":[{"type":"string"},{"type":"null"}]}
}
}`
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
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"},
} {
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)
}
}
}
func TestCleanJSONSchemaForAntigravityResponsePreservesAdditionalPropertiesFalse(t *testing.T) {
input := `{
"type":"object",
"properties":{
"name":{"type":"string"},
"nested":{
"type":"object",
"properties":{
"age":{"type":"integer"}
},
"additionalProperties":false
}
},
"additionalProperties":false
}`
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
// Root additionalProperties should be preserved as false
rootAP := result.Get("additionalProperties")
if !rootAP.Exists() || rootAP.Type != gjson.False {
t.Errorf("root additionalProperties = %v, want false; cleaned: %s", rootAP, result.Raw)
}
// Nested additionalProperties should be preserved as false
nestedAP := result.Get("properties.nested.additionalProperties")
if !nestedAP.Exists() || nestedAP.Type != gjson.False {
t.Errorf("nested additionalProperties = %v, want false; cleaned: %s", nestedAP, result.Raw)
}
// Should not have converted additionalProperties into description hints
if strings.Contains(result.Raw, "No extra properties allowed") {
t.Errorf("expected no description hint for additionalProperties:false, got: %s", result.Raw)
}
// But CleanJSONSchemaForAntigravity (tool path) must still strip it and add hint
toolResult := CleanJSONSchemaForAntigravity(input)
if strings.Contains(toolResult, `"additionalProperties"`) {
t.Errorf("tool schema should not have additionalProperties: %s", toolResult)
}
if !strings.Contains(toolResult, "No extra properties allowed") {
t.Errorf("tool schema should have description hint: %s", toolResult)
}
// Non-false additionalProperties (e.g. true or schema-valued) should still be stripped in response schemas
nonFalseInput := `{
"type":"object",
"properties":{
"map":{"type":"object","additionalProperties":{"type":"string"}}
},
"additionalProperties":true
}`
nonFalseResult := CleanJSONSchemaForAntigravityResponse(nonFalseInput)
if strings.Contains(nonFalseResult, `"additionalProperties"`) {
t.Errorf("non-false additionalProperties should be stripped in response schema: %s", nonFalseResult)
}
}
func TestCleanJSONSchemaForAntigravityResponsePreservesEnumType(t *testing.T) {
input := `{
"type":"object",
"properties":{
"conviction":{"type":"number","enum":[0.25,0.5,1]},
"count":{"type":"integer","enum":[1,2]}
}
}`
result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, testCase := range []struct {
path string
wantType string
wantValues []string
}{
{path: "properties.conviction", wantType: "number", wantValues: []string{"0.25", "0.5", "1"}},
{path: "properties.count", wantType: "integer", wantValues: []string{"1", "2"}},
} {
schema := result.Get(testCase.path)
if gotType := schema.Get("type").String(); gotType != testCase.wantType {
t.Errorf("%s type = %q, want %q: %s", testCase.path, gotType, testCase.wantType, result.Raw)
}
var gotValues []string
for _, enumValue := range schema.Get("enum").Array() {
if enumValue.Type != gjson.String {
t.Errorf("%s enum value is not a string: %s", testCase.path, enumValue.Raw)
}
gotValues = append(gotValues, enumValue.String())
}
if !reflect.DeepEqual(gotValues, testCase.wantValues) {
t.Errorf("%s enum values = %v, want %v: %s", testCase.path, gotValues, testCase.wantValues, result.Raw)
}
}
}
// ============================================================================
// Format field handling (ad-hoc patch removal)
// ============================================================================
func TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) {
// format:"uri" should be removed and added as hint
input := `{
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "A URL"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
// format should be removed
if strings.Contains(result, `"format"`) {
t.Errorf("format field should be removed, got: %s", result)
}
// hint should be added to description
if !strings.Contains(result, "format: uri") {
t.Errorf("format hint should be added to description, got: %s", result)
}
// original description should be preserved
if !strings.Contains(result, "A URL") {
t.Errorf("Original description should be preserved, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_FormatFieldNoDescription(t *testing.T) {
// format without description should create description with hint
input := `{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
// format should be removed
if strings.Contains(result, `"format"`) {
t.Errorf("format field should be removed, got: %s", result)
}
// hint should be added
if !strings.Contains(result, "format: email") {
t.Errorf("format hint should be added, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) {
// Multiple format fields should all be handled
input := `{
"type": "object",
"properties": {
"url": {"type": "string", "format": "uri"},
"email": {"type": "string", "format": "email"},
"date": {"type": "string", "format": "date-time"}
}
}`
result := CleanJSONSchemaForAntigravity(input)
// All format fields should be removed
if strings.Contains(result, `"format"`) {
t.Errorf("All format fields should be removed, got: %s", result)
}
// All hints should be added
if !strings.Contains(result, "format: uri") {
t.Errorf("uri format hint should be added, got: %s", result)
}
if !strings.Contains(result, "format: email") {
t.Errorf("email format hint should be added, got: %s", result)
}
if !strings.Contains(result, "format: date-time") {
t.Errorf("date-time format hint should be added, got: %s", result)
}
}
func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
input := `{
"type": "object",
"properties": {
"priority": {"type": "integer", "enum": [0, 1, 2]},
"level": {"type": "number", "enum": [1.5, 2.5, 3.5]},
"status": {"type": "string", "enum": ["active", "inactive"]}
}
}`
result := CleanJSONSchemaForAntigravity(input)
parsed := gjson.Parse(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)
}
}
}
func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T) {
input := `{
"type": "object",
"properties": {
"enabled": {"type": "boolean", "enum": [true, false]}
}
}`
result := CleanJSONSchemaForAntigravity(input)
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)
}
}
func TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) {
input := `{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "root-schema",
"$comment": "root comment should be removed",
"type": "object",
"properties": {
"payload": {
"type": "object",
"$comment": "nested comment should be removed",
"prefill": "hello",
"properties": {
"mode": {
"type": "string",
"enum": ["a", "b"],
"enumDescriptions": ["Alpha", "Beta"],
"enumTitles": ["A", "B"]
}
},
"patternProperties": {
"^x-": {"type": "string"}
}
},
"$id": {
"type": "string",
"description": "property name should not be removed"
},
"$comment": {
"type": "string",
"description": "property name should not be removed"
},
"enumDescriptions": {
"type": "array",
"description": "property name should not be removed"
}
}
}`
expected := `{
"type": "object",
"properties": {
"payload": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["a", "b"],
"description": "Allowed: a, b"
}
}
},
"$id": {
"type": "string",
"description": "property name should not be removed"
},
"$comment": {
"type": "string",
"description": "property name should not be removed"
},
"enumDescriptions": {
"type": "array",
"description": "property name should not be removed"
}
}
}`
result := CleanJSONSchemaForGemini(input)
compareJSON(t, expected, result)
}
func TestRemoveExtensionFields(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes x- fields at root",
input: `{
"type": "object",
"x-custom-meta": "value",
"properties": {
"foo": { "type": "string" }
}
}`,
expected: `{
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}`,
},
{
name: "removes x- fields in nested properties",
input: `{
"type": "object",
"properties": {
"foo": {
"type": "string",
"x-internal-id": 123
}
}
}`,
expected: `{
"type": "object",
"properties": {
"foo": {
"type": "string"
}
}
}`,
},
{
name: "does NOT remove properties named x-",
input: `{
"type": "object",
"properties": {
"x-data": { "type": "string" },
"normal": { "type": "number", "x-meta": "remove" }
},
"required": ["x-data"]
}`,
expected: `{
"type": "object",
"properties": {
"x-data": { "type": "string" },
"normal": { "type": "number" }
},
"required": ["x-data"]
}`,
},
{
name: "does NOT remove $schema and other meta fields (as requested)",
input: `{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "test",
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}`,
expected: `{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "test",
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}`,
},
{
name: "handles properties named $schema",
input: `{
"type": "object",
"properties": {
"$schema": { "type": "string" }
}
}`,
expected: `{
"type": "object",
"properties": {
"$schema": { "type": "string" }
}
}`,
},
{
name: "handles escaping in paths",
input: `{
"type": "object",
"properties": {
"foo.bar": {
"type": "string",
"x-meta": "remove"
}
},
"x-root.meta": "remove"
}`,
expected: `{
"type": "object",
"properties": {
"foo.bar": {
"type": "string"
}
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := removeExtensionFields(tt.input)
compareJSON(t, tt.expected, actual)
})
}
}
// uniqueItems should be stripped and moved to description hint (#2123).
func TestCleanJSONSchemaForAntigravity_UniqueItemsStripped(t *testing.T) {
input := `{
"type": "object",
"properties": {
"ids": {
"type": "array",
"description": "Unique identifiers",
"items": {"type": "string"},
"uniqueItems": true
}
}
}`
result := CleanJSONSchemaForAntigravity(input)
if strings.Contains(result, `"uniqueItems"`) {
t.Errorf("uniqueItems should be removed from schema")
}
if !strings.Contains(result, "uniqueItems: true") {
t.Errorf("uniqueItems hint missing in description")
}
}
// TestIsPropertyDefinitionDistinguishesPropertyNamedProperties covers the classification that
// decides whether a key spelled like a schema keyword is a keyword or an author-chosen name.
// Matching a trailing ".properties" alone mistook the schema of a property named "properties" for
// a property map, which disabled cleaning inside it.
func TestIsPropertyDefinitionDistinguishesPropertyNamedProperties(t *testing.T) {
for path, want := range map[string]bool{
"": false,
"properties": true,
"properties.properties": false,
"properties.properties.properties": true,
"properties.records.items.properties": true,
"properties.records.items": false,
// Any prefix the caller nests the schema under must not change the answer.
"schema.properties": true,
"request.tools.0.functionDeclarations.0.parameters": false,
"request.tools.0.functionDeclarations.0.parameters.properties": true,
"request.tools.0.functionDeclarations.0.parameters.properties.properties": false,
// $defs and patternProperties are name maps for the same reason as properties.
"$defs": true,
"$defs.properties": false,
"properties.$defs": false,
"properties.a.patternProperties": true,
"properties.patternProperties": false,
} {
if got := isPropertyDefinition(path); got != want {
t.Errorf("isPropertyDefinition(%q) = %v, want %v", path, got, want)
}
}
}
// TestCleanJSONSchemaStripsPropertyNamesUnderPropertyNamedProperties covers the reported failure:
// the private Gemini backend rejects "propertyNames" with an unknown-field 400, and MCP tool
// schemas place it inside a property that is itself named "properties".
func TestCleanJSONSchemaStripsPropertyNamesUnderPropertyNamedProperties(t *testing.T) {
shapes := map[string]string{
// Nested in an array item, alongside the item's own properties map.
"arrayItem": `{"type":"object","properties":{"records":{"type":"array","items":{"type":"object",` +
`"properties":{"name":{"type":"string"}},"propertyNames":{"type":"string"}}}}}`,
// A dynamic map declared by a property named "properties".
"propertyNamedProperties": `{"type":"object","properties":{"properties":{"type":"object",` +
`"propertyNames":{"type":"string"}}}}`,
// Both shapes combined, as the reported tool schemas did.
"combined": `{"type":"object","properties":{"pages":{"type":"array","items":{"type":"object",` +
`"properties":{"properties":{"type":"object","propertyNames":{"type":"string"},` +
`"additionalProperties":true}},"propertyNames":{"type":"string"}}}}}`,
}
for name, schema := range shapes {
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(schema)
if strings.Contains(got, `"propertyNames"`) {
t.Errorf("%s/%s: propertyNames survived cleaning: %s", name, cleaner, got)
}
if strings.Contains(got, `"additionalProperties"`) {
t.Errorf("%s/%s: additionalProperties survived cleaning: %s", name, cleaner, got)
}
}
}
}
// TestCleanJSONSchemaKeepsPropertiesNamedLikeKeywords guards the other half of the rule: a schema
// may legitimately declare properties named after schema keywords, and those must survive.
func TestCleanJSONSchemaKeepsPropertiesNamedLikeKeywords(t *testing.T) {
input := `{"type":"object","properties":{
"propertyNames":{"type":"string"},
"patternProperties":{"type":"string"},
"properties":{"type":"object","properties":{"propertyNames":{"type":"string"}}}
}}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
} {
got := gjson.Parse(clean(input))
for _, path := range []string{
"properties.propertyNames",
"properties.patternProperties",
"properties.properties.properties.propertyNames",
} {
if !got.Get(path).Exists() {
t.Errorf("%s: property %s was removed: %s", cleaner, path, got.Raw)
}
}
}
}
func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
// 1. Root-level if/then/else
rootInput := `{
"type": "object",
"properties": { "kind": { "type": "string", "enum": ["buy", "sell"] } },
"required": ["kind"],
"if": { "properties": { "kind": { "const": "sell" } } },
"then": { "properties": { "sell_reason": { "type": "string", "description": "why the position is being sold" } }, "required": ["sell_reason"] },
"else": { "properties": { "buy_reason": { "type": "string" } } }
}`
for name, clean := range map[string]func(string) string{
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
res := gjson.Parse(clean(rootInput))
if res.Get("if").Exists() {
t.Errorf("[%s] root 'if' was not removed: %s", name, res.Raw)
}
if res.Get("then").Exists() {
t.Errorf("[%s] root 'then' was not removed: %s", name, res.Raw)
}
if res.Get("else").Exists() {
t.Errorf("[%s] root 'else' was not removed: %s", name, res.Raw)
}
if !res.Get("properties.sell_reason").Exists() {
t.Errorf("[%s] then.properties.sell_reason was lost: %s", name, res.Raw)
}
if !res.Get("properties.buy_reason").Exists() {
t.Errorf("[%s] else.properties.buy_reason was lost: %s", name, res.Raw)
}
if res.Get("properties.sell_reason.description").String() != "why the position is being sold" {
t.Errorf("[%s] sell_reason description mismatch: %s", name, res.Raw)
}
}
// 2. allOf with if/then
allOfInput := `{
"type": "object",
"properties": { "kind": { "type": "string", "enum": ["buy", "sell"] } },
"required": ["kind"],
"allOf": [
{
"if": { "properties": { "kind": { "const": "sell" } } },
"then": {
"properties": { "sell_reason": { "type": "string", "description": "why the position is being sold" } },
"required": ["sell_reason"]
}
}
]
}`
for name, clean := range map[string]func(string) string{
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
res := gjson.Parse(clean(allOfInput))
if res.Get("allOf").Exists() {
t.Errorf("[%s] 'allOf' was not removed: %s", name, res.Raw)
}
if res.Get("if").Exists() || strings.Contains(res.Raw, `"if":`) {
t.Errorf("[%s] 'if' keyword present: %s", name, res.Raw)
}
if !res.Get("properties.sell_reason").Exists() {
t.Errorf("[%s] allOf.then.properties.sell_reason was lost: %s", name, res.Raw)
}
if res.Get("properties.sell_reason.description").String() != "why the position is being sold" {
t.Errorf("[%s] sell_reason description mismatch: %s", name, res.Raw)
}
}
// 3. Nested property with if/then
nestedInput := `{
"type": "object",
"properties": {
"trade": {
"type": "object",
"properties": { "kind": { "type": "string" } },
"if": { "properties": { "kind": { "const": "sell" } } },
"then": { "properties": { "sell_reason": { "type": "string" } } }
}
}
}`
for name, clean := range map[string]func(string) string{
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
res := gjson.Parse(clean(nestedInput))
if res.Get("properties.trade.if").Exists() {
t.Errorf("[%s] nested 'if' was not removed: %s", name, res.Raw)
}
if res.Get("properties.trade.then").Exists() {
t.Errorf("[%s] nested 'then' was not removed: %s", name, res.Raw)
}
if !res.Get("properties.trade.properties.sell_reason").Exists() {
t.Errorf("[%s] nested then.properties.sell_reason was lost: %s", name, res.Raw)
}
}
}
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)
}
}
// TestCleanJSONSchemaStripsEncryptedMetadata covers Codex client tool definitions where
// properties carry the Responses-only "encrypted" marker (e.g. "encrypted": true or "encrypted": false).
// The Gemini backend strictly rejects unknown schema fields with an INVALID_ARGUMENT 400.
func TestCleanJSONSchemaStripsEncryptedMetadata(t *testing.T) {
input := `{
"type": "object",
"properties": {
"api_key": {
"type": "string",
"description": "API credential",
"encrypted": true
},
"timeout": {
"type": "integer",
"encrypted": false
},
"nested": {
"type": "object",
"properties": {
"secret": {
"type": "string",
"encrypted": true
}
}
}
},
"required": ["api_key"]
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
if strings.Contains(got, `"encrypted"`) {
t.Errorf("%s: 'encrypted' marker survived cleaning: %s", cleaner, got)
}
parsed := gjson.Parse(got)
if !parsed.Get("properties.api_key.type").Exists() || parsed.Get("properties.api_key.description").String() != "API credential" {
t.Errorf("%s: api_key schema was corrupted: %s", cleaner, got)
}
if !parsed.Get("properties.nested.properties.secret.type").Exists() {
t.Errorf("%s: nested property secret was corrupted: %s", cleaner, got)
}
}
}
// TestCleanJSONSchemaKeepsPropertyNamedEncrypted guards the legitimate case where a tool
// parameter itself is named "encrypted" (e.g. properties.encrypted: {"type": "boolean"}).
func TestCleanJSONSchemaKeepsPropertyNamedEncrypted(t *testing.T) {
input := `{
"type": "object",
"properties": {
"encrypted": {
"type": "boolean",
"description": "Whether the payload is encrypted",
"encrypted": true
},
"data": {
"type": "string"
}
},
"required": ["encrypted"]
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
parsed := gjson.Parse(got)
if !parsed.Get("properties.encrypted").Exists() {
t.Errorf("%s: property named 'encrypted' was removed: %s", cleaner, got)
}
if parsed.Get("properties.encrypted.type").String() != "boolean" {
t.Errorf("%s: property named 'encrypted' type corrupted: %s", cleaner, got)
}
// The inner attribute "encrypted": true must be stripped
if parsed.Get("properties.encrypted.encrypted").Exists() {
t.Errorf("%s: inner 'encrypted' attribute survived: %s", cleaner, got)
}
}
}
// TestCleanJSONSchema_BarePropertyMapNormalized covers Issue #5178:
// MCP tools (e.g. Asana) emit bare property maps missing type:object and properties wrappers,
// plus boolean required: true on child properties.
func TestCleanJSONSchema_BarePropertyMapNormalized(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"parent": { "type": "string", "required": true },
"insert_after": { "type": "string" },
"insert_before": { "type": "string" }
},
"opts": {
"opt_fields": { "type": "string" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"antigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
parsed := gjson.Parse(got)
// data must be normalized into an object schema with properties
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.parent.type").String() != "string" {
t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got)
}
if parsed.Get("properties.data.properties.insert_after.type").String() != "string" {
t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got)
}
if parsed.Get("properties.data.properties.insert_before.type").String() != "string" {
t.Errorf("%s: properties.data.properties.insert_before.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_before.type").String(), got)
}
// parent required: true must be promoted to data.required array
var dataReq []string
for _, r := range parsed.Get("properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "parent") {
t.Errorf("%s: properties.data.required = %v, want 'parent' included; got schema: %s", cleaner, dataReq, got)
}
// boolean required on parent node must be stripped
if parsed.Get("properties.data.properties.parent.required").Exists() {
t.Errorf("%s: properties.data.properties.parent.required survived; got schema: %s", cleaner, got)
}
// opts must also be normalized into an object schema
if parsed.Get("properties.opts.type").String() != "object" {
t.Errorf("%s: properties.opts.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.opts.type").String(), got)
}
if parsed.Get("properties.opts.properties.opt_fields.type").String() != "string" {
t.Errorf("%s: properties.opts.properties.opt_fields.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.opts.properties.opt_fields.type").String(), got)
}
}
}
// TestCleanJSONSchema_NestedBarePropertyMap tests recursive normalization of multi-level bare property maps.
func TestCleanJSONSchema_NestedBarePropertyMap(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"workspace": { "type": "string", "required": true },
"task": {
"name": { "type": "string", "required": true },
"notes": { "type": "string" }
}
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.workspace.type").String() != "string" {
t.Errorf("%s: properties.data.properties.workspace.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.workspace.type").String(), got)
}
// Nested task should also be normalized to an object
if parsed.Get("properties.data.properties.task.type").String() != "object" {
t.Errorf("%s: properties.data.properties.task.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.task.type").String(), got)
}
if parsed.Get("properties.data.properties.task.properties.name.type").String() != "string" {
t.Errorf("%s: properties.data.properties.task.properties.name.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.task.properties.name.type").String(), got)
}
// Required promotion at both levels
var dataReq []string
for _, r := range parsed.Get("properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "workspace") {
t.Errorf("%s: properties.data.required = %v, want 'workspace'; got schema: %s", cleaner, dataReq, got)
}
var taskReq []string
for _, r := range parsed.Get("properties.data.properties.task.required").Array() {
taskReq = append(taskReq, r.String())
}
if !contains(taskReq, "name") {
t.Errorf("%s: properties.data.properties.task.required = %v, want 'name'; got schema: %s", cleaner, taskReq, got)
}
}
}
// TestCleanJSONSchema_BarePropertyMapWithKeywordNames tests that bare property maps with fields
// named like schema keywords (title, description, format, type) are correctly normalized.
func TestCleanJSONSchema_BarePropertyMapWithKeywordNames(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"title": { "type": "string", "required": true },
"description": { "type": "string" },
"format": { "type": "string" },
"type": { "type": "string" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.title.type").String() != "string" {
t.Errorf("%s: properties.data.properties.title.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.title.type").String(), got)
}
if parsed.Get("properties.data.properties.description.type").String() != "string" {
t.Errorf("%s: properties.data.properties.description.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.description.type").String(), got)
}
if parsed.Get("properties.data.properties.type.type").String() != "string" {
t.Errorf("%s: properties.data.properties.type.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.type.type").String(), got)
}
var dataReq []string
for _, r := range parsed.Get("properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "title") {
t.Errorf("%s: properties.data.required = %v, want 'title'; got schema: %s", cleaner, dataReq, got)
}
}
}
// TestCleanJSONSchema_ArrayItemsBarePropertyMap tests bare property map normalization inside array items.
func TestCleanJSONSchema_ArrayItemsBarePropertyMap(t *testing.T) {
input := `{
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {
"id": { "type": "string", "required": true },
"label": { "type": "string" }
}
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.tasks.items.type").String() != "object" {
t.Errorf("%s: properties.tasks.items.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.tasks.items.type").String(), got)
}
if parsed.Get("properties.tasks.items.properties.id.type").String() != "string" {
t.Errorf("%s: properties.tasks.items.properties.id.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.tasks.items.properties.id.type").String(), got)
}
var itemsReq []string
for _, r := range parsed.Get("properties.tasks.items.required").Array() {
itemsReq = append(itemsReq, r.String())
}
if !contains(itemsReq, "id") {
t.Errorf("%s: properties.tasks.items.required = %v, want 'id'; got schema: %s", cleaner, itemsReq, got)
}
}
}
// TestCleanJSONSchema_BooleanRequiredPromoted tests that boolean required: true is promoted
// and boolean required: false is stripped without being added to the required array.
func TestCleanJSONSchema_BooleanRequiredPromoted(t *testing.T) {
input := `{
"type": "object",
"properties": {
"existing": { "type": "string" },
"name": { "type": "string", "required": true },
"age": { "type": "integer", "required": false },
"tag": { "type": "string" }
},
"required": ["existing"]
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
var req []string
for _, r := range parsed.Get("required").Array() {
req = append(req, r.String())
}
if !contains(req, "existing") || !contains(req, "name") {
t.Errorf("%s: required = %v, want both 'existing' and 'name'; got schema: %s", cleaner, req, got)
}
if contains(req, "age") || contains(req, "tag") {
t.Errorf("%s: required = %v, should not contain 'age' or 'tag'; got schema: %s", cleaner, req, got)
}
if parsed.Get("properties.name.required").Exists() {
t.Errorf("%s: properties.name.required survived; got schema: %s", cleaner, got)
}
if parsed.Get("properties.age.required").Exists() {
t.Errorf("%s: properties.age.required survived; got schema: %s", cleaner, got)
}
}
}
// TestCleanJSONSchema_PreservesLargeNumberPrecision tests that numbers are not corrupted by float64 precision loss.
func TestCleanJSONSchema_PreservesLargeNumberPrecision(t *testing.T) {
input := `{
"type": "object",
"properties": {
"big_int": {
"type": "integer",
"minimum": 9007199254740993
},
"bare_child": {
"sub": { "type": "string" }
}
}
}`
result := CleanJSONSchemaForAntigravityResponse(input)
// minimum is moved to description hint
if !strings.Contains(result, "9007199254740993") {
t.Errorf("large integer precision was lost: %s", result)
}
}
// TestCleanJSONSchema_BarePropertyMapWithRequestAndToolsNames tests that property names like
// "request", "tools", "headers", "messages" inside bare property maps are correctly normalized.
func TestCleanJSONSchema_BarePropertyMapWithRequestAndToolsNames(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"request": {
"method": { "type": "string", "required": true },
"url": { "type": "string" }
},
"headers": {
"authorization": { "type": "string" }
},
"tools": {
"name": { "type": "string" }
}
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.headers.type").String() != "object" {
t.Errorf("%s: properties.data.properties.headers.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.headers.type").String(), got)
}
if parsed.Get("properties.data.properties.tools.type").String() != "object" {
t.Errorf("%s: properties.data.properties.tools.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.tools.type").String(), got)
}
if parsed.Get("properties.data.properties.request.type").String() != "object" {
t.Errorf("%s: properties.data.properties.request.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.properties.request.type").String(), got)
}
if parsed.Get("properties.data.properties.request.properties.method.type").String() != "string" {
t.Errorf("%s: properties.data.properties.request.properties.method.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.request.properties.method.type").String(), got)
}
var reqReq []string
for _, r := range parsed.Get("properties.data.properties.request.required").Array() {
reqReq = append(reqReq, r.String())
}
if !contains(reqReq, "method") {
t.Errorf("%s: request.required = %v, want 'method'; got schema: %s", cleaner, reqReq, got)
}
}
}
// TestCleanJSONSchema_BarePropertyMapWithSiblingDescription tests bare property maps with sibling
// annotations (e.g. description, title, required) alongside child property definitions.
func TestCleanJSONSchema_BarePropertyMapWithSiblingDescription(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"description": "Task payload",
"parent": { "type": "string", "required": true },
"insert_after": { "type": "string" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.description").String() != "Task payload" {
t.Errorf("%s: properties.data.description = %q, want 'Task payload'; got schema: %s", cleaner, parsed.Get("properties.data.description").String(), got)
}
if parsed.Get("properties.data.properties.parent.type").String() != "string" {
t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got)
}
if parsed.Get("properties.data.properties.insert_after.type").String() != "string" {
t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got)
}
var dataReq []string
for _, r := range parsed.Get("properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "parent") {
t.Errorf("%s: properties.data.required = %v, want 'parent'; got schema: %s", cleaner, dataReq, got)
}
}
}
// TestCleanJSONSchema_SingleKeySchemaWrapper tests that cleanNestedSchema wrapper {"schema": ...}
// is unwrapped, normalized, and placeholder is properly placed without root pollution.
func TestCleanJSONSchema_SingleKeySchemaWrapper(t *testing.T) {
inner := `{
"type": "object",
"properties": {
"data": {
"parent": { "type": "string", "required": true }
}
}
}`
wrapped := `{"schema": ` + inner + `}`
result := CleanJSONSchemaForAntigravityTool(wrapped, true)
parsed := gjson.Parse(result)
if !parsed.Get("schema").Exists() {
t.Fatalf("wrapper key 'schema' was lost: %s", result)
}
if parsed.Get("schema.properties.data.type").String() != "object" {
t.Errorf("schema.properties.data.type = %q, want object; got: %s", parsed.Get("schema.properties.data.type").String(), result)
}
if parsed.Get("schema.properties.data.properties.parent.type").String() != "string" {
t.Errorf("schema.properties.data.properties.parent.type = %q, want string; got: %s", parsed.Get("schema.properties.data.properties.parent.type").String(), result)
}
var dataReq []string
for _, r := range parsed.Get("schema.properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "parent") {
t.Errorf("schema.properties.data.required = %v, want 'parent'; got: %s", dataReq, result)
}
}
// TestCleanJSONSchema_BarePropertyMapWithExplicitTypeObject tests that nodes declaring
// type: "object" but omitting properties wrapper are correctly normalized.
func TestCleanJSONSchema_BarePropertyMapWithExplicitTypeObject(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"type": "object",
"parent": { "type": "string", "required": true },
"insert_after": { "type": "string" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.parent.type").String() != "string" {
t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got)
}
if parsed.Get("properties.data.properties.insert_after.type").String() != "string" {
t.Errorf("%s: properties.data.properties.insert_after.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.insert_after.type").String(), got)
}
var dataReq []string
for _, r := range parsed.Get("properties.data.required").Array() {
dataReq = append(dataReq, r.String())
}
if !contains(dataReq, "parent") {
t.Errorf("%s: properties.data.required = %v, want 'parent'; got schema: %s", cleaner, dataReq, got)
}
}
}
// TestCleanJSONSchema_BarePropertyMapWithNullable tests bare property maps with nullable: true.
func TestCleanJSONSchema_BarePropertyMapWithNullable(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"nullable": true,
"description": "Task payload",
"parent": { "type": "string" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
}
if parsed.Get("properties.data.properties.parent.type").String() != "string" {
t.Errorf("%s: properties.data.properties.parent.type = %q, want string; got schema: %s", cleaner, parsed.Get("properties.data.properties.parent.type").String(), got)
}
}
}
// TestCleanJSONSchema_PreservesHTMLCharactersWithoutEscaping tests that < > & in descriptions
// are not converted into HTML entities (\u003c, \u003e, \u0026).
func TestCleanJSONSchema_PreservesHTMLCharactersWithoutEscaping(t *testing.T) {
input := `{
"type": "object",
"properties": {
"data": {
"description": "Uses <tag> & symbols > threshold",
"parent": { "type": "string" }
}
}
}`
result := CleanJSONSchemaForAntigravityResponse(input)
if strings.Contains(result, `\u003c`) || strings.Contains(result, `\u003e`) || strings.Contains(result, `\u0026`) {
t.Errorf("HTML characters were escaped: %s", result)
}
if !strings.Contains(result, "<tag>") || !strings.Contains(result, "& symbols >") {
t.Errorf("Original description with HTML characters was corrupted: %s", result)
}
}
// TestCleanJSONSchema_VendorExtensionOnEnumNotWrappedIntoProperties tests that vendor extensions
// on non-object types (e.g. x-google-enum-descriptions on a string enum) are not wrapped into properties.
func TestCleanJSONSchema_VendorExtensionOnEnumNotWrappedIntoProperties(t *testing.T) {
input := `{
"type": "string",
"enum": ["FOO", "BAR"],
"x-google-enum-descriptions": {
"FOO": "Foo option",
"BAR": "Bar option"
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties").Exists() {
t.Errorf("%s: string enum gained unexpected properties: %s", cleaner, got)
}
if parsed.Get("type").String() != "string" {
t.Errorf("%s: string type corrupted: %s", cleaner, got)
}
}
}
// TestCleanJSONSchema_ObjectDefaultNotWrappedIntoProperties tests that object-typed default
// is not wrapped into properties as an orphan bare property.
func TestCleanJSONSchema_ObjectDefaultNotWrappedIntoProperties(t *testing.T) {
input := `{
"type": "object",
"properties": {
"settings": {
"type": "object",
"default": { "theme": "dark", "lang": "en" }
}
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
// settings must not gain properties.default.properties.theme
if parsed.Get("properties.settings.properties.default").Exists() {
t.Errorf("%s: default was converted to property: %s", cleaner, got)
}
}
}
// TestCleanJSONSchema_MixedPropertiesAndOrphanBareProperty tests that orphan bare property maps
// alongside an existing properties object are collected into properties.
func TestCleanJSONSchema_MixedPropertiesAndOrphanBareProperty(t *testing.T) {
input := `{
"type": "object",
"properties": {
"foo": { "type": "string" }
},
"bar": {
"type": "integer",
"required": true
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
parsed := gjson.Parse(got)
if parsed.Get("properties.foo.type").String() != "string" {
t.Errorf("%s: foo corrupted: %s", cleaner, got)
}
if parsed.Get("properties.bar.type").String() != "integer" {
t.Errorf("%s: orphan bar was not moved to properties: %s", cleaner, got)
}
var req []string
for _, r := range parsed.Get("required").Array() {
req = append(req, r.String())
}
if !contains(req, "bar") {
t.Errorf("%s: bar required not promoted: %s", cleaner, got)
}
if parsed.Get("bar").Exists() {
t.Errorf("%s: top-level bar survived: %s", cleaner, got)
}
}
}
// TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema tests that a standalone
// additionalProperties schema is recognized as a structural keyword and not wrapped as a property.
func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T) {
input := `{
"additionalProperties": {
"type": "string"
}
}`
for cleaner, clean := range map[string]func(string) string{
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
parsed := gjson.Parse(got)
// Should not be wrapped as properties.additionalProperties
if parsed.Get("properties.additionalProperties").Exists() {
t.Errorf("%s: additionalProperties was wrapped into properties: %s", cleaner, got)
}
}
}