2234 lines
69 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|