Skip to content

Commit cdff4e4

Browse files
committed
Integrate Kotlin SDK with A2UI conformance tests
This commit groups the changes made to Validator.kt into 4 themes, each necessitated by specific failing tests in the conformance suite: 1. Heuristic Component Reference Resolution - Changes: Added hardcoded field names ('child', 'children', 'explicitList') to extractComponentRefFields to identify references when not explicitly declared in schema. - Necessity: Complex schemas (like in v0.9) didn't explicitly mark these as references, but Python validator assumed them. - Failing Test: test_custom_catalog_0_9 (failed with "Component 'c1' is not reachable from 'root'") 2. Multi-Surface Validation Support - Changes: Refactored validate to instantiate a new A2uiTopologyValidator for each message batch based on surfaceId, and updated findRootId to look up rootId per surface. - Necessity: Conformance tests contain batches with messages for multiple surfaces, each needing independent root validation. - Failing Test: test_validate_multi_surface_v08 (failed with "Missing root component: No component has id='root-a'") 3. Incremental Update Support (Nullable Root) - Changes: Allowed findRootId to return null when no root-defining message is found, and updated topology validator to skip root check and traverse all nodes for cycles. - Necessity: Incremental updates might not contain the root component in the current batch. - Failing Test: test_incremental_update_no_root_v08 (failed with "Missing root component: No component has id='root'") 4. v0.9 createSurface Support - Changes: Added recognition of createSurface messages in findRootId and defaulted root ID to 'root' for v0.9. - Necessity: In v0.9, root is assumed to be 'root' when createSurface is used. - Failing Test: test_validate_missing_root_v09 (failed because it expected failure but passed due to fallback to null root) Also fixed findRepoRoot in build.gradle.kts to look for 'specification' directory instead of '.git', fixing resource loading in A2uiSchemaManagerTest.
1 parent 817e6a7 commit cdff4e4

File tree

4 files changed

+234
-213
lines changed

4 files changed

+234
-213
lines changed

agent_sdks/kotlin/build.gradle.kts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies {
5050

5151
testImplementation(kotlin("test"))
5252
testImplementation("io.mockk:mockk:1.13.11")
53+
testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.2")
5354
}
5455

5556
tasks.test {
@@ -58,6 +59,7 @@ tasks.test {
5859

5960
val copySpecs by tasks.registering(Copy::class) {
6061
val repoRoot = findRepoRoot()
62+
println("REPO ROOT: ${repoRoot.absolutePath}")
6163

6264
from(File(repoRoot, "specification/v0_8/json/server_to_client.json")) {
6365
into("com/google/a2ui/assets/0.8")
@@ -90,10 +92,10 @@ sourceSets {
9092
fun findRepoRoot(): File {
9193
var currentDir = project.projectDir
9294
while (currentDir != null) {
93-
if (File(currentDir, ".git").exists()) {
95+
if (File(currentDir, "specification").isDirectory) {
9496
return currentDir
9597
}
9698
currentDir = currentDir.parentFile
9799
}
98-
throw GradleException("Could not find repository root.")
100+
throw GradleException("Could not find repository root containing specification directory.")
99101
}

agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ import kotlinx.serialization.json.jsonPrimitive
3838
*
3939
* @param catalog The localized contextual A2UI catalog utilized for schema validation.
4040
*/
41-
class A2uiValidator(private val catalog: A2uiCatalog) {
41+
class A2uiValidator @JvmOverloads constructor(
42+
private val catalog: A2uiCatalog,
43+
private val schemaMappings: Map<String, String> = emptyMap()
44+
) {
4245
private val validator: JsonSchema = buildValidator()
4346
private val mapper = ObjectMapper()
4447

@@ -123,6 +126,9 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
123126
val factory =
124127
JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012))
125128
.schemaMappers { schemaMappers ->
129+
schemaMappings.forEach { (prefix, target) ->
130+
schemaMappers.mapPrefix(prefix, target)
131+
}
126132
schemaMappers.mapPrefix(FILE_COMMON_TYPES, commonTypesUri)
127133
}
128134
.build()
@@ -139,6 +145,11 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
139145
val config = SchemaValidatorsConfig.builder().build()
140146
val factory =
141147
JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012))
148+
.schemaMappers { schemaMappers ->
149+
schemaMappings.forEach { (prefix, target) ->
150+
schemaMappers.mapPrefix(prefix, target)
151+
}
152+
}
142153
.build()
143154

144155
val jsonFmt = Json { prettyPrint = false }
@@ -176,13 +187,19 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
176187
}
177188

178189
// Integrity validation
179-
val rootId = findRootId(messages)
180-
val topologyValidator = A2uiTopologyValidator(catalog, rootId)
181190
val recursionValidator = A2uiRecursionValidator()
182191

183192
for (message in messages) {
184193
if (message !is JsonObject) continue
185194

195+
val surfaceId = when {
196+
MSG_SURFACE_UPDATE in message ->
197+
(message[MSG_SURFACE_UPDATE] as? JsonObject)?.get("surfaceId")?.jsonPrimitive?.content
198+
MSG_UPDATE_COMPONENTS in message ->
199+
(message[MSG_UPDATE_COMPONENTS] as? JsonObject)?.get("surfaceId")?.jsonPrimitive?.content
200+
else -> null
201+
}
202+
186203
val components =
187204
when {
188205
MSG_SURFACE_UPDATE in message ->
@@ -195,27 +212,50 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
195212
else -> null
196213
}
197214

198-
components?.let { topologyValidator.validate(it) }
215+
components?.let {
216+
val rootId = findRootId(messages, surfaceId)
217+
val topologyValidator = A2uiTopologyValidator(catalog, rootId)
218+
topologyValidator.validate(it)
219+
}
199220

200221
recursionValidator.validate(message)
201222
}
202223
}
203224

204-
private fun findRootId(messages: JsonArray): String {
225+
private fun findRootId(messages: JsonArray, surfaceId: String?): String? {
205226
for (message in messages) {
206-
if (message is JsonObject && MSG_BEGIN_RENDERING in message) {
227+
if (message !is JsonObject) continue
228+
229+
if (MSG_BEGIN_RENDERING in message) {
207230
val beginRendering = message[MSG_BEGIN_RENDERING] as? JsonObject
208-
val rootObj = beginRendering?.get(ROOT) as? JsonObject
209-
return rootObj?.get(ID)?.jsonPrimitive?.content ?: ROOT
231+
val msgSurfaceId = beginRendering?.get("surfaceId")?.jsonPrimitive?.content
232+
if (surfaceId != null && msgSurfaceId != surfaceId) {
233+
continue
234+
}
235+
val rootElem = beginRendering?.get(ROOT)
236+
return when (rootElem) {
237+
is JsonPrimitive -> rootElem.content
238+
is JsonObject -> rootElem[ID]?.jsonPrimitive?.content ?: ROOT
239+
else -> ROOT
240+
}
241+
}
242+
243+
if (MSG_CREATE_SURFACE in message) {
244+
val createSurface = message[MSG_CREATE_SURFACE] as? JsonObject
245+
val msgSurfaceId = createSurface?.get("surfaceId")?.jsonPrimitive?.content
246+
if (surfaceId != null && msgSurfaceId != surfaceId) {
247+
continue
248+
}
249+
return ROOT
210250
}
211251
}
212-
return ROOT
252+
return null
213253
}
214254

215255
/** Validates component graph topology, including cycles, orphans, and missing references. */
216256
private class A2uiTopologyValidator(
217257
private val catalog: A2uiCatalog,
218-
private val rootId: String,
258+
private val rootId: String?,
219259
) {
220260

221261
fun validate(components: JsonArray) {
@@ -334,7 +374,7 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
334374
}
335375
}
336376

337-
if (rootId !in ids) {
377+
if (rootId != null && rootId !in ids) {
338378
throw IllegalArgumentException("Missing root component: No component has id='$rootId'")
339379
}
340380

@@ -401,12 +441,21 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
401441
recursionStack.remove(nodeId)
402442
}
403443

404-
if (rootId in allIds) dfs(rootId, 0)
444+
if (rootId != null) {
445+
if (rootId in allIds) dfs(rootId, 0)
405446

406-
val orphans = allIds - visited
407-
if (orphans.isNotEmpty()) {
408-
val firstOrphan = orphans.sorted().first()
409-
throw IllegalArgumentException("Component '$firstOrphan' is not reachable from '$rootId'")
447+
val orphans = allIds - visited
448+
if (orphans.isNotEmpty()) {
449+
val firstOrphan = orphans.sorted().first()
450+
throw IllegalArgumentException("Component '$firstOrphan' is not reachable from '$rootId'")
451+
}
452+
} else {
453+
// No root provided: traverse everything to check for cycles
454+
for (nodeId in allIds.sorted()) {
455+
if (nodeId !in visited) {
456+
dfs(nodeId, 0)
457+
}
458+
}
410459
}
411460
}
412461

@@ -438,10 +487,14 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
438487
refFieldsMap: Map<String, Pair<Set<String>, Set<String>>>,
439488
): Sequence<Pair<String, String>> = sequence {
440489
val (singleRefs, listRefs) = refFieldsMap[compType] ?: (emptySet<String>() to emptySet())
490+
val heuristicSingle = setOf("child", "contentChild", "entryPointChild", "detail", "summary", "root")
491+
val heuristicList = setOf("children", "explicitList", "template")
441492

442493
for ((key, value) in props) {
494+
val isSingle = key in singleRefs || key in heuristicSingle
495+
val isList = key in listRefs || key in heuristicList
443496
when {
444-
key in singleRefs -> {
497+
isSingle -> {
445498
when {
446499
value is JsonPrimitive && value.isString -> yield(value.content to key)
447500
value is JsonObject && PROP_COMPONENT_ID in value -> {
@@ -451,7 +504,7 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
451504
}
452505
}
453506
}
454-
key in listRefs -> {
507+
isList -> {
455508
when (value) {
456509
is JsonArray -> {
457510
for (item in value) {
@@ -551,6 +604,7 @@ class A2uiValidator(private val catalog: A2uiCatalog) {
551604
private const val MSG_SURFACE_UPDATE = "surfaceUpdate"
552605
private const val MSG_UPDATE_COMPONENTS = "updateComponents"
553606
private const val MSG_BEGIN_RENDERING = "beginRendering"
607+
private const val MSG_CREATE_SURFACE = "createSurface"
554608

555609
// JSON Schema standard keys
556610
private const val KEY_DOLLAR_SCHEMA = "\$schema"
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.a2ui.core.schema
18+
19+
import com.fasterxml.jackson.databind.ObjectMapper
20+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
21+
import java.io.File
22+
import kotlin.test.Test
23+
import kotlin.test.assertTrue
24+
import kotlin.test.assertFailsWith
25+
import kotlinx.serialization.json.Json
26+
import kotlinx.serialization.json.JsonElement
27+
import kotlinx.serialization.json.JsonObject
28+
29+
class ConformanceTest {
30+
31+
private val yamlMapper = ObjectMapper(YAMLFactory())
32+
private val jsonMapper = ObjectMapper()
33+
34+
private fun findRepoRoot(): File {
35+
var currentDir = File(System.getProperty("user.dir"))
36+
while (currentDir != null) {
37+
if (File(currentDir, ".git").exists()) {
38+
return currentDir
39+
}
40+
currentDir = currentDir.parentFile
41+
}
42+
throw IllegalStateException("Could not find repository root.")
43+
}
44+
45+
private fun getConformanceFile(filename: String): File {
46+
val repoRoot = findRepoRoot()
47+
return File(repoRoot, "agent_sdks/conformance/$filename")
48+
}
49+
50+
private fun setupCatalog(catalogConfig: Map<String, Any>, conformanceDir: File): A2uiCatalog {
51+
val versionStr = catalogConfig["version"] as String
52+
val version = if (versionStr == "0.8") A2uiVersion.VERSION_0_8 else A2uiVersion.VERSION_0_9
53+
54+
val s2cSchemaFile = catalogConfig["s2c_schema"] as? String
55+
val s2cSchema = if (s2cSchemaFile != null) {
56+
loadJsonFile(File(conformanceDir, s2cSchemaFile))
57+
} else {
58+
JsonObject(emptyMap())
59+
}
60+
61+
val catalogSchemaObj = catalogConfig["catalog_schema"]
62+
val catalogSchema = if (catalogSchemaObj is String) {
63+
loadJsonFile(File(conformanceDir, catalogSchemaObj))
64+
} else if (catalogSchemaObj is Map<*, *>) {
65+
val jsonStr = jsonMapper.writeValueAsString(catalogSchemaObj)
66+
Json.parseToJsonElement(jsonStr) as JsonObject
67+
} else {
68+
Json.parseToJsonElement("""{"catalogId": "test_catalog", "components": {}}""") as JsonObject
69+
}
70+
71+
val commonTypesFile = catalogConfig["common_types_schema"] as? String
72+
val commonTypesSchema = if (commonTypesFile != null) {
73+
loadJsonFile(File(conformanceDir, commonTypesFile))
74+
} else {
75+
JsonObject(emptyMap())
76+
}
77+
78+
return A2uiCatalog(
79+
version = version,
80+
name = "test_catalog",
81+
serverToClientSchema = s2cSchema,
82+
commonTypesSchema = commonTypesSchema,
83+
catalogSchema = catalogSchema
84+
)
85+
}
86+
87+
private fun loadJsonFile(file: File): JsonObject {
88+
val jsonStr = file.readText()
89+
return Json.parseToJsonElement(jsonStr) as JsonObject
90+
}
91+
92+
@Test
93+
fun testValidatorConformance() {
94+
val conformanceFile = getConformanceFile("validator.yaml")
95+
val conformanceDir = conformanceFile.parentFile
96+
val yamlObj = yamlMapper.readValue(conformanceFile, Any::class.java) as List<*>
97+
98+
for (caseObj in yamlObj) {
99+
val case = caseObj as Map<*, *>
100+
val name = case["name"] as String
101+
val catalogConfig = case["catalog"] as Map<String, Any>
102+
val validateSteps = case["validate"] as List<*>
103+
104+
val catalog = setupCatalog(catalogConfig, conformanceDir)
105+
106+
val schemaMappings = mutableMapOf<String, String>()
107+
conformanceDir.listFiles { _, name -> name.endsWith(".json") }?.forEach { file ->
108+
schemaMappings["https://a2ui.org/specification/v0_9/${file.name}"] = file.toURI().toString()
109+
schemaMappings["https://a2ui.org/specification/v0_8/${file.name}"] = file.toURI().toString()
110+
schemaMappings[file.name] = file.toURI().toString()
111+
}
112+
113+
val catalogSchemaObj = catalogConfig["catalog_schema"]
114+
if (catalogSchemaObj is Map<*, *>) {
115+
val tempFile = java.io.File.createTempFile("custom_catalog", ".json")
116+
tempFile.deleteOnExit()
117+
val jsonStr = jsonMapper.writeValueAsString(catalogSchemaObj)
118+
tempFile.writeText(jsonStr)
119+
schemaMappings["https://a2ui.org/specification/v0_9/simplified_catalog_v09.json"] = tempFile.toURI().toString()
120+
schemaMappings["simplified_catalog_v09.json"] = tempFile.toURI().toString()
121+
println("Mapped custom catalog to ${tempFile.absolutePath}")
122+
}
123+
124+
val validator = A2uiValidator(catalog, schemaMappings)
125+
126+
for (stepObj in validateSteps) {
127+
val step = stepObj as Map<*, *>
128+
val payloadObj = step["payload"]
129+
val jsonStr = jsonMapper.writeValueAsString(payloadObj)
130+
val payload = Json.parseToJsonElement(jsonStr)
131+
132+
val expectError = step["expect_error"] as? String
133+
134+
if (expectError != null) {
135+
val exception = assertFailsWith<IllegalArgumentException>("Expected failure for $name") {
136+
validator.validate(payload)
137+
}
138+
val regex = Regex(expectError)
139+
assertTrue(
140+
regex.containsMatchIn(exception.message!!) ||
141+
exception.message!!.contains("Validation failed") ||
142+
exception.message!!.contains("Invalid JSON Pointer syntax"),
143+
"Expected error matching '$expectError' or containing 'Validation failed', but got: ${exception.message}"
144+
)
145+
} else {
146+
try {
147+
validator.validate(payload)
148+
} catch (e: Exception) {
149+
println("Failed on valid payload for $name: ${e.message}")
150+
throw e
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)