Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions pkg/config/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ package config

import (
"io/fs"
"os"
"path/filepath"
"testing"

"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"

"github.com/docker/cagent/pkg/config/latest"
"github.com/docker/cagent/pkg/modelsdev"
Expand Down Expand Up @@ -78,32 +76,6 @@ func TestParseExamples(t *testing.T) {
}
}

func TestJsonSchemaWorksForExamples(t *testing.T) {
// Read json schema.
schemaFile, err := os.ReadFile(filepath.Join("..", "..", "agent-schema.json"))
require.NoError(t, err)

schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaFile))
require.NoError(t, err)

for _, file := range collectExamples(t) {
t.Run(file, func(t *testing.T) {
t.Parallel()

buf, err := os.ReadFile(file)
require.NoError(t, err)

var rawJSON any
err = yaml.Unmarshal(buf, &rawJSON)
require.NoError(t, err)

result, err := schema.Validate(gojsonschema.NewRawLoader(rawJSON))
require.NoError(t, err)
assert.True(t, result.Valid(), "Example %s does not match schema: %v", file, result.Errors())
})
}
}

func TestParseExamplesAfterMarshalling(t *testing.T) {
for _, file := range collectExamples(t) {
t.Run(file, func(t *testing.T) {
Expand Down
205 changes: 112 additions & 93 deletions pkg/config/latest/schema_test.go → pkg/config/schema_test.go
Original file line number Diff line number Diff line change
@@ -1,93 +1,47 @@
package latest
package config

import (
"encoding/json"
"maps"
"os"
"reflect"
"sort"
"strings"
"testing"

"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"

"github.com/docker/cagent/pkg/config/latest"
)

// schemaFile is the path to the JSON schema file relative to the repo root.
const schemaFile = "../../../agent-schema.json"
const schemaFile = "../../agent-schema.json"

// jsonSchema mirrors the subset of JSON Schema we need for comparison.
type jsonSchema struct {
Properties map[string]jsonSchema `json:"properties,omitempty"`
Definitions map[string]jsonSchema `json:"definitions,omitempty"`
Ref string `json:"$ref,omitempty"`
Items *jsonSchema `json:"items,omitempty"`
AdditionalProperties any `json:"additionalProperties,omitempty"`
}
func TestJsonSchemaWorksForExamples(t *testing.T) {
schemaFile, err := os.ReadFile(schemaFile)
require.NoError(t, err)

// resolveRef follows a $ref like "#/definitions/Foo" and returns the
// referenced schema. When no $ref is present it returns the receiver unchanged.
func (s jsonSchema) resolveRef(root jsonSchema) jsonSchema {
if s.Ref == "" {
return s
}
const prefix = "#/definitions/"
if !strings.HasPrefix(s.Ref, prefix) {
return s
}
name := strings.TrimPrefix(s.Ref, prefix)
if def, ok := root.Definitions[name]; ok {
return def
}
return s
}
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaFile))
require.NoError(t, err)

// structJSONFields returns the set of JSON property names declared on a Go
// struct type via `json:"<name>,…"` tags. Fields tagged with `json:"-"` are
// excluded. It recurses into anonymous (embedded) struct fields so that
// promoted fields are included.
func structJSONFields(t reflect.Type) map[string]bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
fields := make(map[string]bool)
for f := range t.Fields() {
// Recurse into anonymous (embedded) structs.
if f.Anonymous {
maps.Copy(fields, structJSONFields(f.Type))
continue
}
for _, file := range collectExamples(t) {
t.Run(file, func(t *testing.T) {
t.Parallel()

tag := f.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name, _, _ := strings.Cut(tag, ",")
if name != "" && name != "-" {
fields[name] = true
}
}
return fields
}
buf, err := os.ReadFile(file)
require.NoError(t, err)

// schemaProperties returns the set of property names from a JSON schema
// definition. It does NOT follow $ref on individual properties – it only
// looks at the top-level "properties" map.
func schemaProperties(def jsonSchema) map[string]bool {
props := make(map[string]bool, len(def.Properties))
for k := range def.Properties {
props[k] = true
}
return props
}
var rawJSON any
err = yaml.Unmarshal(buf, &rawJSON)
require.NoError(t, err)

func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
result, err := schema.Validate(gojsonschema.NewRawLoader(rawJSON))
require.NoError(t, err)
assert.True(t, result.Valid(), "Example %s does not match schema: %v", file, result.Errors())
})
}
sort.Strings(keys)
return keys
}

// TestSchemaMatchesGoTypes verifies that every JSON-tagged field in the Go
Expand Down Expand Up @@ -116,27 +70,27 @@ func TestSchemaMatchesGoTypes(t *testing.T) {

entries := []entry{
// Top-level Config
{reflect.TypeFor[Config](), root, "Config (top-level)"},
{reflect.TypeFor[latest.Config](), root, "Config (top-level)"},
}

// Definitions that map 1:1 to a Go struct.
definitionMap := map[string]reflect.Type{
"AgentConfig": reflect.TypeFor[AgentConfig](),
"FallbackConfig": reflect.TypeFor[FallbackConfig](),
"ModelConfig": reflect.TypeFor[ModelConfig](),
"Metadata": reflect.TypeFor[Metadata](),
"ProviderConfig": reflect.TypeFor[ProviderConfig](),
"Toolset": reflect.TypeFor[Toolset](),
"Remote": reflect.TypeFor[Remote](),
"SandboxConfig": reflect.TypeFor[SandboxConfig](),
"ScriptShellToolConfig": reflect.TypeFor[ScriptShellToolConfig](),
"PostEditConfig": reflect.TypeFor[PostEditConfig](),
"PermissionsConfig": reflect.TypeFor[PermissionsConfig](),
"HooksConfig": reflect.TypeFor[HooksConfig](),
"HookMatcherConfig": reflect.TypeFor[HookMatcherConfig](),
"HookDefinition": reflect.TypeFor[HookDefinition](),
"RoutingRule": reflect.TypeFor[RoutingRule](),
"ApiConfig": reflect.TypeFor[APIToolConfig](),
"AgentConfig": reflect.TypeFor[latest.AgentConfig](),
"FallbackConfig": reflect.TypeFor[latest.FallbackConfig](),
"ModelConfig": reflect.TypeFor[latest.ModelConfig](),
"Metadata": reflect.TypeFor[latest.Metadata](),
"ProviderConfig": reflect.TypeFor[latest.ProviderConfig](),
"Toolset": reflect.TypeFor[latest.Toolset](),
"Remote": reflect.TypeFor[latest.Remote](),
"SandboxConfig": reflect.TypeFor[latest.SandboxConfig](),
"ScriptShellToolConfig": reflect.TypeFor[latest.ScriptShellToolConfig](),
"PostEditConfig": reflect.TypeFor[latest.PostEditConfig](),
"PermissionsConfig": reflect.TypeFor[latest.PermissionsConfig](),
"HooksConfig": reflect.TypeFor[latest.HooksConfig](),
"HookMatcherConfig": reflect.TypeFor[latest.HookMatcherConfig](),
"HookDefinition": reflect.TypeFor[latest.HookDefinition](),
"RoutingRule": reflect.TypeFor[latest.RoutingRule](),
"ApiConfig": reflect.TypeFor[latest.APIToolConfig](),
}

for name, goType := range definitionMap {
Expand All @@ -156,13 +110,13 @@ func TestSchemaMatchesGoTypes(t *testing.T) {
}

inlines := []inlineEntry{
{reflect.TypeFor[StructuredOutput](), []string{"AgentConfig", "structured_output"}, "StructuredOutput (AgentConfig.structured_output)"},
{reflect.TypeFor[RAGConfig](), []string{"RAGConfig"}, "RAGConfig"},
{reflect.TypeFor[RAGToolConfig](), []string{"RAGConfig", "tool"}, "RAGToolConfig (RAGConfig.tool)"},
{reflect.TypeFor[RAGResultsConfig](), []string{"RAGConfig", "results"}, "RAGResultsConfig (RAGConfig.results)"},
{reflect.TypeFor[RAGFusionConfig](), []string{"RAGConfig", "results", "fusion"}, "RAGFusionConfig (RAGConfig.results.fusion)"},
{reflect.TypeFor[RAGRerankingConfig](), []string{"RAGConfig", "results", "reranking"}, "RAGRerankingConfig (RAGConfig.results.reranking)"},
{reflect.TypeFor[RAGChunkingConfig](), []string{"RAGConfig", "strategies", "*", "chunking"}, "RAGChunkingConfig (RAGConfig.strategies[].chunking)"},
{reflect.TypeFor[latest.StructuredOutput](), []string{"AgentConfig", "structured_output"}, "StructuredOutput (AgentConfig.structured_output)"},
{reflect.TypeFor[latest.RAGConfig](), []string{"RAGConfig"}, "RAGConfig"},
{reflect.TypeFor[latest.RAGToolConfig](), []string{"RAGConfig", "tool"}, "RAGToolConfig (RAGConfig.tool)"},
{reflect.TypeFor[latest.RAGResultsConfig](), []string{"RAGConfig", "results"}, "RAGResultsConfig (RAGConfig.results)"},
{reflect.TypeFor[latest.RAGFusionConfig](), []string{"RAGConfig", "results", "fusion"}, "RAGFusionConfig (RAGConfig.results.fusion)"},
{reflect.TypeFor[latest.RAGRerankingConfig](), []string{"RAGConfig", "results", "reranking"}, "RAGRerankingConfig (RAGConfig.results.reranking)"},
{reflect.TypeFor[latest.RAGChunkingConfig](), []string{"RAGConfig", "strategies", "*", "chunking"}, "RAGChunkingConfig (RAGConfig.strategies[].chunking)"},
}

for _, il := range inlines {
Expand All @@ -185,6 +139,71 @@ func TestSchemaMatchesGoTypes(t *testing.T) {
}
}

// jsonSchema mirrors the subset of JSON Schema we need for comparison.
type jsonSchema struct {
Properties map[string]jsonSchema `json:"properties,omitempty"`
Definitions map[string]jsonSchema `json:"definitions,omitempty"`
Ref string `json:"$ref,omitempty"`
Items *jsonSchema `json:"items,omitempty"`
AdditionalProperties any `json:"additionalProperties,omitempty"`
}

// resolveRef follows a $ref like "#/definitions/Foo" and returns the
// referenced schema. When no $ref is present it returns the receiver unchanged.
func (s jsonSchema) resolveRef(root jsonSchema) jsonSchema {
if s.Ref == "" {
return s
}
const prefix = "#/definitions/"
if !strings.HasPrefix(s.Ref, prefix) {
return s
}
name := strings.TrimPrefix(s.Ref, prefix)
if def, ok := root.Definitions[name]; ok {
return def
}
return s
}

// structJSONFields returns the set of JSON property names declared on a Go
// struct type via `json:"<name>,…"` tags. Fields tagged with `json:"-"` are
// excluded. It recurses into anonymous (embedded) struct fields so that
// promoted fields are included.
func structJSONFields(t reflect.Type) map[string]bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
fields := make(map[string]bool)
for f := range t.Fields() {
// Recurse into anonymous (embedded) structs.
if f.Anonymous {
maps.Copy(fields, structJSONFields(f.Type))
continue
}

tag := f.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name, _, _ := strings.Cut(tag, ",")
if name != "" && name != "-" {
fields[name] = true
}
}
return fields
}

// schemaProperties returns the set of property names from a JSON schema
// definition. It does NOT follow $ref on individual properties – it only
// looks at the top-level "properties" map.
func schemaProperties(def jsonSchema) map[string]bool {
props := make(map[string]bool, len(def.Properties))
for k := range def.Properties {
props[k] = true
}
return props
}

// navigateSchema walks from a top-level definition through nested properties.
// path[0] is the definition name; subsequent elements are property names.
// The special element "*" dereferences an array's "items" schema.
Expand Down
Loading