mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 11:07:03 +00:00
ef829c1ab4
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
591 lines
17 KiB
Go
591 lines
17 KiB
Go
package providers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// $ref resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveRefs_Simple(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"addr": map[string]any{"$ref": "#/$defs/Address"},
|
|
},
|
|
"$defs": map[string]any{
|
|
"Address": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"street": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
addr := prop(result, "addr")
|
|
if addr == nil {
|
|
t.Fatal("expected addr property")
|
|
}
|
|
if _, ok := addr["$ref"]; ok {
|
|
t.Error("$ref should be resolved")
|
|
}
|
|
if prop(addr, "street") == nil {
|
|
t.Error("expected inlined street property from resolved ref")
|
|
}
|
|
}
|
|
|
|
func TestResolveRefs_Circular(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"node": map[string]any{"$ref": "#/$defs/Node"},
|
|
},
|
|
"$defs": map[string]any{
|
|
"Node": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"child": map[string]any{"$ref": "#/$defs/Node"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
// Use "anthropic" to test ref resolution in isolation (no strict mode transform).
|
|
result := NormalizeSchema("anthropic", schema)
|
|
node := prop(result, "node")
|
|
if node == nil {
|
|
t.Fatal("expected node property")
|
|
}
|
|
// Circular child should be a stub, not infinite.
|
|
child := prop(node, "child")
|
|
if child == nil {
|
|
t.Fatal("expected child property (circular stub)")
|
|
}
|
|
if child["type"] != "object" {
|
|
t.Error("circular stub should have type:object")
|
|
}
|
|
}
|
|
|
|
func TestResolveRefs_LegacyDefinitions(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"item": map[string]any{"$ref": "#/definitions/Item"},
|
|
},
|
|
"definitions": map[string]any{
|
|
"Item": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
// Use "anthropic" to test ref resolution in isolation (no strict mode transform).
|
|
result := NormalizeSchema("anthropic", schema)
|
|
item := prop(result, "item")
|
|
if item == nil || item["type"] != "string" {
|
|
t.Error("expected definitions/ ref resolved to string type")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Null variant stripping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestStripNullVariants_AnyOf(t *testing.T) {
|
|
schema := map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{"type": "string"},
|
|
map[string]any{"type": "null"},
|
|
},
|
|
}
|
|
// Gemini profile strips null variants.
|
|
result := NormalizeSchema("gemini", schema)
|
|
if _, ok := result["anyOf"]; ok {
|
|
t.Error("expected anyOf removed after null stripping + flattening")
|
|
}
|
|
if result["type"] != "string" {
|
|
t.Errorf("expected type:string, got %v", result["type"])
|
|
}
|
|
}
|
|
|
|
func TestStripNullVariants_TypeArray(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": []any{"string", "null"},
|
|
}
|
|
result := NormalizeSchema("gemini", schema)
|
|
if result["type"] != "string" {
|
|
t.Errorf("expected type:string, got %v", result["type"])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Union flattening
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFlattenUnions_ObjectMerge(t *testing.T) {
|
|
schema := map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"action": map[string]any{"const": "create"}, "name": map[string]any{"type": "string"}},
|
|
"required": []any{"action", "name"},
|
|
},
|
|
map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"action": map[string]any{"const": "delete"}, "id": map[string]any{"type": "number"}},
|
|
"required": []any{"action", "id"},
|
|
},
|
|
},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
if result["type"] != "object" {
|
|
t.Errorf("expected type:object, got %v", result["type"])
|
|
}
|
|
props, _ := result["properties"].(map[string]any)
|
|
if props == nil {
|
|
t.Fatal("expected merged properties")
|
|
}
|
|
for _, key := range []string{"action", "name", "id"} {
|
|
if props[key] == nil {
|
|
t.Errorf("expected property %q in merged result", key)
|
|
}
|
|
}
|
|
req, _ := result["required"].([]any)
|
|
// "action" is required in both variants → should be in intersection.
|
|
found := false
|
|
for _, r := range req {
|
|
if r == "action" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 'action' in required (intersection of both variants)")
|
|
}
|
|
}
|
|
|
|
func TestFlattenUnions_LiteralConstNoType(t *testing.T) {
|
|
// M4: variants with const but no explicit type — type should be inferred.
|
|
schema := map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{"const": "x"},
|
|
map[string]any{"const": "y"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
if result["type"] != "string" {
|
|
t.Errorf("expected inferred type:string, got %v", result["type"])
|
|
}
|
|
enum, ok := result["enum"].([]any)
|
|
if !ok || len(enum) != 2 {
|
|
t.Fatalf("expected enum with 2 values, got %v", result["enum"])
|
|
}
|
|
}
|
|
|
|
func TestFlattenUnions_LiteralConst(t *testing.T) {
|
|
schema := map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{"const": "a", "type": "string"},
|
|
map[string]any{"const": "b", "type": "string"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
if result["type"] != "string" {
|
|
t.Errorf("expected type:string, got %v", result["type"])
|
|
}
|
|
enum, ok := result["enum"].([]any)
|
|
if !ok || len(enum) != 2 {
|
|
t.Fatalf("expected enum with 2 values, got %v", result["enum"])
|
|
}
|
|
if enum[0] != "a" || enum[1] != "b" {
|
|
t.Errorf("expected enum [a,b], got %v", enum)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Const → enum
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConvertConst(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"mode": map[string]any{"const": "fast"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("gemini", schema)
|
|
mode := prop(result, "mode")
|
|
if mode == nil {
|
|
t.Fatal("expected mode property")
|
|
}
|
|
if _, ok := mode["const"]; ok {
|
|
t.Error("const should be converted to enum")
|
|
}
|
|
enum, ok := mode["enum"].([]any)
|
|
if !ok || len(enum) != 1 || enum[0] != "fast" {
|
|
t.Errorf("expected enum:[fast], got %v", mode["enum"])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// type:"object" injection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestInjectObjectType(t *testing.T) {
|
|
schema := map[string]any{
|
|
"properties": map[string]any{
|
|
"name": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"name"},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
if result["type"] != "object" {
|
|
t.Errorf("expected type:object injected, got %v", result["type"])
|
|
}
|
|
}
|
|
|
|
func TestInjectObjectType_SkipsWhenTypeExists(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "array",
|
|
"properties": map[string]any{
|
|
"x": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
if result["type"] != "array" {
|
|
t.Error("should not overwrite existing type")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Remove type on union (Gemini)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRemoveTypeOnUnion(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"anyOf": []any{
|
|
map[string]any{"type": "string"},
|
|
map[string]any{"type": "number"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("gemini", schema)
|
|
if _, ok := result["type"]; ok {
|
|
t.Error("type should be removed when anyOf present (Gemini)")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Key stripping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestStripKeys_Gemini(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"minimum": 1.0,
|
|
"maximum": 10.0,
|
|
"format": "int32",
|
|
"pattern": "^[a-z]+$",
|
|
"properties": map[string]any{
|
|
"count": map[string]any{
|
|
"type": "number",
|
|
"minimum": 0.0,
|
|
"maxLength": 100,
|
|
},
|
|
},
|
|
}
|
|
result := NormalizeSchema("gemini", schema)
|
|
for _, key := range []string{"minimum", "maximum", "format", "pattern"} {
|
|
if _, ok := result[key]; ok {
|
|
t.Errorf("expected %q stripped for Gemini", key)
|
|
}
|
|
}
|
|
count := prop(result, "count")
|
|
if count == nil {
|
|
t.Fatal("expected count property")
|
|
}
|
|
if _, ok := count["minimum"]; ok {
|
|
t.Error("expected nested minimum stripped")
|
|
}
|
|
if _, ok := count["maxLength"]; ok {
|
|
t.Error("expected nested maxLength stripped")
|
|
}
|
|
}
|
|
|
|
func TestStripKeys_EmptyRequired(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"required": []any{},
|
|
"properties": map[string]any{},
|
|
}
|
|
result := NormalizeSchema("gemini", schema)
|
|
if _, ok := result["required"]; ok {
|
|
t.Error("expected empty required:[] stripped for Gemini")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// End-to-end per provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestNormalizeSchema_Anthropic(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"url": map[string]any{"$ref": "#/$defs/URL"},
|
|
},
|
|
"$defs": map[string]any{"URL": map[string]any{"type": "string", "format": "uri"}},
|
|
"anyOf": []any{map[string]any{"type": "object"}, map[string]any{"type": "null"}},
|
|
}
|
|
result := NormalizeSchema("anthropic", schema)
|
|
// $ref resolved
|
|
url := prop(result, "url")
|
|
if url == nil || url["type"] != "string" {
|
|
t.Error("expected $ref resolved to string type")
|
|
}
|
|
// format preserved (Anthropic supports it)
|
|
if url["format"] != "uri" {
|
|
t.Error("expected format preserved for Anthropic")
|
|
}
|
|
// anyOf preserved (Anthropic handles unions natively)
|
|
if _, ok := result["anyOf"]; !ok {
|
|
t.Error("expected anyOf preserved for Anthropic (no flatten)")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSchema_Codex(t *testing.T) {
|
|
schema := map[string]any{
|
|
"properties": map[string]any{
|
|
"name": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
result := NormalizeSchema("codex", schema)
|
|
if result["type"] != "object" {
|
|
t.Error("expected type:object injected for Codex")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSchema_XAI(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]any{
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 500,
|
|
},
|
|
},
|
|
}
|
|
result := NormalizeSchema("xai", schema)
|
|
query := prop(result, "query")
|
|
if query == nil {
|
|
t.Fatal("expected query property")
|
|
}
|
|
if _, ok := query["minLength"]; ok {
|
|
t.Error("expected minLength stripped for xAI")
|
|
}
|
|
if _, ok := query["maxLength"]; ok {
|
|
t.Error("expected maxLength stripped for xAI")
|
|
}
|
|
if query["type"] != "string" {
|
|
t.Error("expected type preserved")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSchema_Nil(t *testing.T) {
|
|
if result := NormalizeSchema("openai", nil); result != nil {
|
|
t.Error("expected nil for nil schema")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSchema_DoesNotMutateOriginal(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"x": map[string]any{"$ref": "#/$defs/X"},
|
|
},
|
|
"$defs": map[string]any{"X": map[string]any{"type": "string"}},
|
|
}
|
|
original, _ := json.Marshal(schema)
|
|
_ = NormalizeSchema("openai", schema)
|
|
after, _ := json.Marshal(schema)
|
|
if string(original) != string(after) {
|
|
t.Error("NormalizeSchema should not mutate the original schema")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Strict tool mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestApplyStrictMode_Basic(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"url": map[string]any{"type": "string", "description": "The URL"},
|
|
"api_key": map[string]any{"type": "string", "description": "API key"},
|
|
"timeout": map[string]any{"type": "number", "description": "Timeout"},
|
|
},
|
|
"required": []any{"url"},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
|
|
// All properties should be required.
|
|
reqArr, _ := result["required"].([]any)
|
|
reqSet := make(map[string]bool, len(reqArr))
|
|
for _, r := range reqArr {
|
|
reqSet[r.(string)] = true
|
|
}
|
|
for _, name := range []string{"url", "api_key", "timeout"} {
|
|
if !reqSet[name] {
|
|
t.Errorf("expected %q in required array", name)
|
|
}
|
|
}
|
|
|
|
// additionalProperties should be false.
|
|
if result["additionalProperties"] != false {
|
|
t.Error("expected additionalProperties:false")
|
|
}
|
|
|
|
// Required param 'url' should keep original type.
|
|
urlProp := prop(result, "url")
|
|
if urlProp["type"] != "string" {
|
|
t.Errorf("expected url type:string, got %v", urlProp["type"])
|
|
}
|
|
|
|
// Optional params should be nullable.
|
|
apiKeyProp := prop(result, "api_key")
|
|
apiKeyType, ok := apiKeyProp["type"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected api_key type to be array, got %T: %v", apiKeyProp["type"], apiKeyProp["type"])
|
|
}
|
|
hasNull := false
|
|
for _, v := range apiKeyType {
|
|
if v == "null" {
|
|
hasNull = true
|
|
}
|
|
}
|
|
if !hasNull {
|
|
t.Error("expected api_key type to include 'null'")
|
|
}
|
|
|
|
timeoutProp := prop(result, "timeout")
|
|
timeoutType, ok := timeoutProp["type"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected timeout type to be array, got %T", timeoutProp["type"])
|
|
}
|
|
hasNull = false
|
|
for _, v := range timeoutType {
|
|
if v == "null" {
|
|
hasNull = true
|
|
}
|
|
}
|
|
if !hasNull {
|
|
t.Error("expected timeout type to include 'null'")
|
|
}
|
|
}
|
|
|
|
func TestApplyStrictMode_NestedObject(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"config": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"key": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
"required": []any{},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
|
|
// Nested object should also have additionalProperties:false.
|
|
config := prop(result, "config")
|
|
if config == nil {
|
|
t.Fatal("expected config property")
|
|
}
|
|
if config["additionalProperties"] != false {
|
|
t.Error("expected nested object to have additionalProperties:false")
|
|
}
|
|
}
|
|
|
|
// TestApplyStrictMode_BareObjectProperty reproduces the use_skill tool
|
|
// failure: an optional property declared as `{"type":"object","description":...}`
|
|
// with NO nested `properties`. Pre-fix, applyStrictMode early-returned on
|
|
// this node (no "properties") so additionalProperties was never set, then
|
|
// makeNullable turned the type into ["object","null"], and OpenAI rejected
|
|
// with "invalid_function_parameters: 'additionalProperties' is required to
|
|
// be supplied and to be false" at path ('properties', 'params', 'type', '0').
|
|
func TestApplyStrictMode_BareObjectProperty(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"name": map[string]any{"type": "string"},
|
|
"params": map[string]any{
|
|
"type": "object",
|
|
"description": "Optional skill-specific parameters",
|
|
},
|
|
},
|
|
"required": []any{"name"},
|
|
}
|
|
result := NormalizeSchema("openai", schema)
|
|
|
|
params := prop(result, "params")
|
|
if params == nil {
|
|
t.Fatal("expected params property to survive normalization")
|
|
}
|
|
if params["additionalProperties"] != false {
|
|
t.Errorf("bare object property must get additionalProperties:false; got %v", params["additionalProperties"])
|
|
}
|
|
// And makeNullable should have turned type into ["object","null"].
|
|
typ, ok := params["type"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected params.type to be a []any union, got %T: %v", params["type"], params["type"])
|
|
}
|
|
hasObject, hasNull := false, false
|
|
for _, v := range typ {
|
|
switch v {
|
|
case "object":
|
|
hasObject = true
|
|
case "null":
|
|
hasNull = true
|
|
}
|
|
}
|
|
if !hasObject || !hasNull {
|
|
t.Errorf("expected params.type to contain both 'object' and 'null'; got %v", typ)
|
|
}
|
|
}
|
|
|
|
func TestApplyStrictMode_SkipsAnthropic(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"url": map[string]any{"type": "string"},
|
|
"debug": map[string]any{"type": "boolean"},
|
|
},
|
|
"required": []any{"url"},
|
|
}
|
|
result := NormalizeSchema("anthropic", schema)
|
|
|
|
// Anthropic should NOT get strict mode transforms.
|
|
if result["additionalProperties"] == false {
|
|
t.Error("Anthropic should not have additionalProperties:false")
|
|
}
|
|
debugProp := prop(result, "debug")
|
|
if debugProp["type"] != "boolean" {
|
|
t.Error("Anthropic should keep original type (no nullable transform)")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func prop(schema map[string]any, name string) map[string]any {
|
|
props, ok := schema["properties"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m, _ := props[name].(map[string]any)
|
|
return m
|
|
}
|