diff --git a/agent_sdks/kotlin/.gitignore b/agent_sdks/kotlin/.gitignore new file mode 100644 index 000000000..b26b35b0e --- /dev/null +++ b/agent_sdks/kotlin/.gitignore @@ -0,0 +1,128 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Gradle template +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Kotlin template +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + diff --git a/agent_sdks/kotlin/build.gradle.kts b/agent_sdks/kotlin/build.gradle.kts new file mode 100644 index 000000000..e65b33b0d --- /dev/null +++ b/agent_sdks/kotlin/build.gradle.kts @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("jvm") version "2.1.10" + kotlin("plugin.serialization") version "2.1.10" + id("java-library") + id("com.ncorti.ktfmt.gradle") version "0.19.0" + id("org.jetbrains.kotlinx.kover") version "0.9.1" +} + +ktfmt { + googleStyle() +} + +version = "0.1.0" +group = "com.google.a2ui" + +kotlin { + jvmToolchain(21) +} + +repositories { + mavenCentral() +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("com.networknt:json-schema-validator:1.5.1") + implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") + + // Core Dependencies + api("com.google.adk:google-adk:0.9.0") + api("com.google.adk:google-adk-a2a:0.9.0") + api("io.github.a2asdk:a2a-java-sdk-client:1.0.0.Alpha3") + api("com.google.genai:google-genai:1.43.0") + + testImplementation(kotlin("test")) + testImplementation("io.mockk:mockk:1.13.11") +} + +tasks.test { + useJUnitPlatform() +} + +val copySpecs by tasks.registering(Copy::class) { + val repoRoot = findRepoRoot() + + from(File(repoRoot, "specification/v0_8/json/server_to_client.json")) { + into("com/google/a2ui/assets/0.8") + } + from(File(repoRoot, "specification/v0_8/json/standard_catalog_definition.json")) { + into("com/google/a2ui/assets/0.8") + } + + from(File(repoRoot, "specification/v0_9/json/server_to_client.json")) { + into("com/google/a2ui/assets/0.9") + } + from(File(repoRoot, "specification/v0_9/json/common_types.json")) { + into("com/google/a2ui/assets/0.9") + } + from(File(repoRoot, "specification/v0_9/json/basic_catalog.json")) { + into("com/google/a2ui/assets/0.9") + } + + into(layout.buildDirectory.dir("generated/resources/specs")) +} + +sourceSets { + main { + resources { + srcDir(copySpecs) + } + } +} + +fun findRepoRoot(): File { + var currentDir = project.projectDir + while (currentDir != null) { + if (File(currentDir, ".git").exists()) { + return currentDir + } + currentDir = currentDir.parentFile + } + throw GradleException("Could not find repository root.") +} diff --git a/agent_sdks/kotlin/gradle/wrapper/gradle-wrapper.properties b/agent_sdks/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..dbc3ce4a0 --- /dev/null +++ b/agent_sdks/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/agent_sdks/kotlin/gradlew b/agent_sdks/kotlin/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/agent_sdks/kotlin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/agent_sdks/kotlin/gradlew.bat b/agent_sdks/kotlin/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/agent_sdks/kotlin/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/agent_sdks/kotlin/settings.gradle.kts b/agent_sdks/kotlin/settings.gradle.kts new file mode 100644 index 000000000..5a01945cb --- /dev/null +++ b/agent_sdks/kotlin/settings.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +rootProject.name = "a2ui-agent" diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2aHandler.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2aHandler.kt new file mode 100644 index 000000000..0f6962cd1 --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2aHandler.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.a2a + +import com.google.a2ui.core.parser.hasA2uiParts +import com.google.a2ui.core.parser.parseResponseToParts +import com.google.adk.agents.RunConfig +import com.google.adk.events.Event +import com.google.adk.runner.Runner +import com.google.adk.sessions.Session +import com.google.adk.sessions.SessionKey +import com.google.genai.types.Content +import com.google.genai.types.Part +import java.util.Optional +import java.util.UUID +import java.util.logging.Logger +import kotlinx.serialization.json.* + +/** + * Simplifies implementing the Agent-to-App (A2A) protocol for ADK agents without needing heavy + * server dependencies. + */ +class A2aHandler(private val runner: Runner) { + + /** Handles the /.well-known/agent-card.json HTTP GET request. */ + fun handleAgentCardGet( + agentName: String, + serverUrl: String, + supportedCatalogIds: List = emptyList(), + ): Map { + return mapOf( + "name" to agentName, + "url" to serverUrl, + "endpoints" to mapOf("chat" to serverUrl), + "capabilities" to + mapOf( + "streaming" to true, + "extensions" to + listOf( + mapOf( + "uri" to A2uiA2a.A2UI_EXTENSION_URI, + "params" to mapOf("supportedCatalogIds" to supportedCatalogIds), + ) + ), + ), + ) + } + + /** Handles the /a2a HTTP POST JSON-RPC request. */ + @JvmOverloads + fun handleA2aPost( + requestBody: Map, + sessionPreparer: ((Session, Map<*, *>) -> Unit)? = null, + ): Map { + val method = requestBody["method"] as? String + val id = requestBody["id"] ?: "" + val response = mutableMapOf("jsonrpc" to "2.0", "id" to id) + + try { + if (method == "a2a.agent.card.get") { + response["result"] = handleAgentCardGet(runner.appName(), "/a2a") + } else if (method == "a2a.agent.invoke" || method == "message/send") { + val params = requestBody["params"] as? Map<*, *> + val messageMap = params?.get("message") as? Map<*, *> + + if (messageMap == null) { + response["error"] = mapOf("code" to -32602, "message" to "Invalid params") + return response + } + + val contextId = messageMap["contextId"] as? String ?: DEFAULT_CONTEXT_ID + val sessionId = contextId + val userId = A2A_USER_ID + + val content = extractUserContent(messageMap) + val session = getOrCreateSession(userId, sessionId) + + sessionPreparer?.invoke(session, requestBody) + + val events = + runner.runAsync(session, content, RunConfig.builder().build()).toList().blockingGet() + + val allParts = translateEventsToA2aParts(events) + response["result"] = createFinalMessage(contextId, events, allParts) + } else { + response["error"] = mapOf("code" to -32601, "message" to "Method not found") + } + } catch (e: Exception) { + logger.severe(e.message) + response["error"] = mapOf("code" to -32000, "message" to (e.message ?: "Unknown error")) + } + + return response + } + + private fun extractUserContent(messageMap: Map<*, *>): Content { + val partsList = messageMap["parts"] as? List<*> ?: emptyList() + val userText = + partsList + .filterIsInstance>() + .filter { it["kind"] == "text" } + .joinToString(separator = "") { it["text"] as? String ?: "" } + return Content.builder() + .role("user") + .parts(listOf(Part.builder().text(userText).build())) + .build() + } + + private fun getOrCreateSession(userId: String, sessionId: String): Session { + var session = + runner + .sessionService() + .getSession(runner.appName(), userId, sessionId, Optional.empty()) + .blockingGet() + if (session == null) { + session = + runner + .sessionService() + .createSession(SessionKey(runner.appName(), userId, sessionId)) + .blockingGet() + } + return session + } + + private fun translateEventsToA2aParts(events: List<*>): List> { + return events.filterIsInstance().flatMap { event -> + if (event.content().isPresent && event.content().get().parts().isPresent) { + event.content().get().parts().get().flatMap { part -> processPart(part) } + } else { + emptyList() + } + } + } + + private fun processPart(part: Part): List> { + val parsedParts = mutableListOf>() + + val functionCall = part.functionCall().orElse(null) + if (functionCall != null && functionCall.name().orElse(null) == "send_a2ui_json_to_client") { + val argsMap = functionCall.args().orElse(null) as? Map<*, *> + val a2uiJsonStr = argsMap?.get("a2ui_json") as? String + if (a2uiJsonStr != null) { + processA2uiJsonFunctionArg(a2uiJsonStr, parsedParts) + } + } + + val text = part.text().orElse("")?.trim() ?: "" + if (text.isEmpty()) return parsedParts + + if (hasA2uiParts(text)) { + processA2uiTextParts(text, parsedParts) + } else { + parsedParts.add(mapOf("kind" to "text", "text" to text)) + } + + return parsedParts + } + + private fun processA2uiJsonFunctionArg( + a2uiJsonStr: String, + parsedParts: MutableList>, + ) { + try { + val element = Json.parseToJsonElement(a2uiJsonStr) + val data = jsonElementToAny(element) + if (data is Map<*, *> || data is List<*>) { + parsedParts.add( + mapOf( + "kind" to "data", + "metadata" to mapOf(A2uiA2a.MIME_TYPE_KEY to A2uiA2a.A2UI_MIME_TYPE), + "data" to data, + ) + ) + } + } catch (e: Exception) { + logger.severe(e.message) + } + } + + private fun processA2uiTextParts(text: String, parsedParts: MutableList>) { + try { + val responseParts = parseResponseToParts(text) + for (responsePart in responseParts) { + if (responsePart.text.isNotBlank()) { + parsedParts.add(mapOf("kind" to "text", "text" to responsePart.text.trim())) + } + + responsePart.a2uiJson?.forEach { element -> + val data = jsonElementToAny(element) + if (data != null) { + parsedParts.add( + mapOf( + "kind" to "data", + "metadata" to mapOf(A2uiA2a.MIME_TYPE_KEY to A2uiA2a.A2UI_MIME_TYPE), + "data" to data, + ) + ) + } + } + } + } catch (e: Exception) { + logger.severe(e.message) + parsedParts.add(mapOf("kind" to "text", "text" to text)) + } + } + + private fun createFinalMessage( + contextId: String, + events: List<*>, + allParts: List>, + ): Map { + val lastEvent = events.lastOrNull() as? Event + return mapOf( + "messageId" to (lastEvent?.id() ?: UUID.randomUUID().toString()), + "contextId" to contextId, + "role" to "model", + "kind" to "message", + "parts" to allParts, + ) + } + + private fun jsonElementToAny(element: JsonElement): Any? = + when (element) { + is JsonObject -> element.mapValues { jsonElementToAny(it.value) } + is JsonArray -> element.map { jsonElementToAny(it) } + is JsonPrimitive -> { + if (element.isString) element.content + else if (element.booleanOrNull != null) element.booleanOrNull + else if (element.longOrNull != null) element.longOrNull + else if (element.doubleOrNull != null) element.doubleOrNull else null + } + } + + private companion object { + val logger: Logger = Logger.getLogger(A2aHandler::class.java.name) + const val A2A_USER_ID = "a2a-user" + const val DEFAULT_CONTEXT_ID = "default-context" + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2uiA2a.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2uiA2a.kt new file mode 100644 index 000000000..eaa8f1cbb --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/a2a/A2uiA2a.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.a2a + +import com.google.a2ui.core.schema.A2uiConstants +import io.a2a.spec.AgentExtension +import io.a2a.spec.DataPart +import io.a2a.spec.Part +import kotlinx.serialization.json.JsonElement + +/** A2A protocol helpers for A2UI integration. */ +object A2uiA2a { + const val A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.8" + const val MIME_TYPE_KEY = "mimeType" + const val A2UI_MIME_TYPE = "application/json+a2ui" + + /** Creates an A2A Part containing A2UI data. */ + fun createA2uiPart(a2uiData: JsonElement): Part<*> = + DataPart(a2uiData, mapOf(MIME_TYPE_KEY to A2UI_MIME_TYPE)) + + /** Checks if an A2A Part contains A2UI data. */ + fun isA2uiPart(part: Part<*>): Boolean = + part is DataPart && part.metadata?.get(MIME_TYPE_KEY) == A2UI_MIME_TYPE + + /** Extracts the A2UI data from an A2A Part if present. */ + fun getA2uiData(part: Part<*>): JsonElement? = + if (isA2uiPart(part)) (part as DataPart).data as? JsonElement else null + + /** Creates the A2UI AgentExtension configuration. */ + fun getA2uiAgentExtension( + acceptsInlineCatalogs: Boolean = false, + supportedCatalogIds: List = emptyList(), + ): AgentExtension { + val params = mutableMapOf() + if (acceptsInlineCatalogs) { + params[A2uiConstants.INLINE_CATALOGS_KEY] = true + } + if (supportedCatalogIds.isNotEmpty()) { + params[A2uiConstants.SUPPORTED_CATALOG_IDS_KEY] = supportedCatalogIds + } + + val isSupportRequired = false + return AgentExtension( + A2UI_EXTENSION_URI, + params, + isSupportRequired, + "Provides agent driven UI using the A2UI JSON format.", + ) + } + + /** + * Activates the A2UI extension if requested in the context. + * + * @param requestedExtensions List of extension URIs requested by the client. + * @param addActivatedExtension Callback to register an activated extension. + * @return True if A2UI was activated, false otherwise. + */ + fun tryActivateA2uiExtension( + requestedExtensions: List, + addActivatedExtension: (String) -> Unit, + ): Boolean { + if (A2UI_EXTENSION_URI in requestedExtensions) { + addActivatedExtension(A2UI_EXTENSION_URI) + return true + } + return false + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/Converters.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/Converters.kt new file mode 100644 index 000000000..eef1d9fb6 --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/Converters.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.adk.a2a_extension + +import com.google.a2ui.a2a.A2uiA2a +import com.google.a2ui.core.parser.hasA2uiParts +import com.google.a2ui.core.parser.parseResponseToParts +import com.google.a2ui.core.schema.A2uiCatalog +import com.google.adk.a2a.converters.EventConverter +import com.google.adk.agents.InvocationContext +import com.google.adk.events.Event +import com.google.genai.types.Content +import com.google.genai.types.Part +import io.a2a.spec.Event as A2aEvent +import io.a2a.spec.Message +import io.a2a.spec.Message.Role.ROLE_AGENT +import io.a2a.spec.TaskState +import io.a2a.spec.TaskStatus +import io.a2a.spec.TaskStatusUpdateEvent +import io.a2a.spec.TextPart +import java.time.OffsetDateTime +import java.util.Optional +import java.util.UUID.randomUUID +import java.util.logging.Logger +import kotlinx.serialization.json.JsonElement + +/** A catalog-aware GenAI to A2A part converter. */ +class A2uiPartConverter( + private val catalog: A2uiCatalog, + private val bypassToolCheck: Boolean = false, +) { + private val logger = Logger.getLogger(A2uiPartConverter::class.java.name) + + // Note: Due to missing public API for PartConverter in ADK Java SDK, falling back + // to returning DataParts for A2UI, and omitting standard conversions here. + // Client applications should adapt this integration logic based on actual available converters. + + fun convert(part: Part): List> { + val functionResponse = part.functionResponse().orElse(null) + val isSendA2uiJsonToClientResponse = + functionResponse != null && + functionResponse.name().orElse(null) == SendA2uiToClientToolset.TOOL_NAME + + if (isSendA2uiJsonToClientResponse || bypassToolCheck) { + if (functionResponse == null) return emptyList() + val responseMap = functionResponse.response().orElse(null) as? Map<*, *> + + responseMap?.get(SendA2uiToClientToolset.TOOL_ERROR_KEY)?.let { + logger.warning("A2UI tool call failed: $it") + return emptyList() + } + + return (responseMap?.get(SendA2uiToClientToolset.VALIDATED_A2UI_JSON_KEY) as? JsonElement) + ?.let { listOf(A2uiA2a.createA2uiPart(it)) } ?: emptyList() + } + + val functionCall = part.functionCall().orElse(null) + if ( + functionCall != null && functionCall.name().orElse(null) == SendA2uiToClientToolset.TOOL_NAME + ) { + return emptyList() + } + + val text = part.text().orElse(null) ?: return emptyList() + return if (hasA2uiParts(text)) { + parseResponseToParts(text, catalog.validator).flatMap { responsePart -> + responsePart.a2uiJson?.map { A2uiA2a.createA2uiPart(it) } ?: emptyList() + } + } else { + emptyList() + } + } +} + +/** An event converter that automatically injects the A2UI catalog into part conversion. */ +class A2uiEventConverter( + private val catalogKey: String = "system:a2ui_catalog", + private val bypassToolCheck: Boolean = false, +) { + + fun convert( + event: Event, + invocationContext: InvocationContext, + taskId: String? = null, + contextId: String? = null, + ): List { + val catalog = + invocationContext.session().state()[catalogKey] as? A2uiCatalog ?: return emptyList() + + val converter = A2uiPartConverter(catalog, bypassToolCheck) + val events = mutableListOf() + + // 1. Process Errors + val errorCode = event.errorCode().orElse(null) + if (errorCode != null) { + val errorMessage = event.errorMessage().orElse("An error occurred during processing") + val errorMsgObj = + Message.builder() + .messageId(randomUUID().toString()) + .role(ROLE_AGENT) + .parts(listOf(TextPart(errorMessage))) + .build() + + val status = TaskStatus(TaskState.TASK_STATE_FAILED, errorMsgObj, OffsetDateTime.now()) + + events.add( + TaskStatusUpdateEvent.builder() + .taskId(taskId ?: EventConverter.taskId(event)) + .contextId(contextId ?: EventConverter.contextId(event)) + .status(status) + .build() + ) + } + + // 2. Process Content + val content = event.content().orElse(null) + if (content != null) { + val outputParts = mutableListOf>() + + val genaiParts = content.parts().orElse(emptyList()) ?: emptyList() + for (part in genaiParts) { + val a2uiParts = converter.convert(part) + if (a2uiParts.isNotEmpty()) { + outputParts.addAll(a2uiParts) + } else { + // Fallback to standard GenAI to A2A Part conversion using ADK's internal EventConverter + // helpers + val singleContent = Content.builder().role("model").parts(listOf(part)).build() + val standardParts = EventConverter.contentToParts(Optional.of(singleContent), false) + outputParts.addAll(standardParts) + } + } + + if (outputParts.isNotEmpty()) { + val msgObj = + Message.builder() + .messageId(randomUUID().toString()) + .role(ROLE_AGENT) + .parts(outputParts) + .build() + + val status = TaskStatus(TaskState.TASK_STATE_WORKING, msgObj, OffsetDateTime.now()) + + events.add( + TaskStatusUpdateEvent.builder() + .taskId(taskId ?: EventConverter.taskId(event)) + .contextId(contextId ?: EventConverter.contextId(event)) + .status(status) + .build() + ) + } + } + + return events + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolset.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolset.kt new file mode 100644 index 000000000..457be882f --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolset.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.adk.a2a_extension + +import com.google.a2ui.core.schema.A2uiCatalog +import com.google.adk.agents.ReadonlyContext +import com.google.adk.models.LlmRequest +import com.google.adk.tools.BaseTool +import com.google.adk.tools.BaseToolset +import com.google.adk.tools.ToolContext +import com.google.genai.types.FunctionDeclaration +import com.google.genai.types.Schema +import com.google.genai.types.Type +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import java.util.logging.Logger + +typealias A2uiEnabledProvider = (ReadonlyContext) -> Boolean + +typealias A2uiCatalogProvider = (ReadonlyContext) -> A2uiCatalog + +typealias A2uiExamplesProvider = (ReadonlyContext) -> String + +/** + * A toolset that furnishes ADK agents with A2UI (Agent-to-UI) functional tools. + * + * This allows an agent to explicitly decide to render UI components based on the configured schemas + * and current catalog capabilities. Features dynamic enablement, pulling localized components on + * demand. + */ +class SendA2uiToClientToolset +@JvmOverloads +constructor( + private val a2uiEnabled: A2uiEnabledProvider, + private val a2uiCatalog: A2uiCatalogProvider, + private val a2uiExamples: A2uiExamplesProvider, +) : BaseToolset { + + private val logger = Logger.getLogger(SendA2uiToClientToolset::class.java.name) + private val uiTools = listOf(SendA2uiJsonToClientTool()) + + override fun getTools(readonlyContext: ReadonlyContext): Flowable = + if (a2uiEnabled(readonlyContext)) { + logger.info("A2UI is ENABLED, adding ui tools") + Flowable.fromIterable(uiTools) + } else { + logger.info("A2UI is DISABLED, not adding ui tools") + Flowable.empty() + } + + override fun close() { + // Nothing to close + } + + fun getPartConverter(ctx: ReadonlyContext): A2uiPartConverter = + A2uiPartConverter(a2uiCatalog(ctx)) + + inner class SendA2uiJsonToClientTool : + BaseTool( + TOOL_NAME, + "Sends A2UI JSON to the client to render rich UI natively. Always prefer this over returning raw JSON.", + ) { + + override fun declaration(): Optional { + return Optional.of( + FunctionDeclaration.builder() + .name(name()) + .description(description()) + .parameters( + Schema.builder() + .type(Type(Type.Known.OBJECT)) + .properties( + mapOf( + A2UI_JSON_ARG_NAME to + Schema.builder() + .type(Type(Type.Known.STRING)) + .description("The A2UI JSON payload to send to the client.") + .build() + ) + ) + .build() + ) + .build() + ) + } + + override fun processLlmRequest( + llmRequestBuilder: LlmRequest.Builder, + toolContext: ToolContext, + ): Completable = + Completable.fromAction { + val catalog = a2uiCatalog(toolContext) + val instruction = catalog.renderAsLlmInstructions() + val examples = a2uiExamples(toolContext) + + llmRequestBuilder.appendInstructions(listOf(instruction, examples)) + logger.info("Added A2UI schema and examples to system instructions") + } + .andThen(super.processLlmRequest(llmRequestBuilder, toolContext)) + + override fun runAsync( + args: Map, + toolContext: ToolContext, + ): Single> = + Single.fromCallable { + try { + val a2uiJsonStr = + args[A2UI_JSON_ARG_NAME] as? String + ?: throw IllegalArgumentException( + "Failed to call tool $TOOL_NAME because missing required arg $A2UI_JSON_ARG_NAME" + ) + + val catalog = a2uiCatalog(toolContext) + + val a2uiJsonPayload = kotlinx.serialization.json.Json.parseToJsonElement(a2uiJsonStr) + catalog.validator.validate(a2uiJsonPayload) + + logger.info("Validated call to tool $TOOL_NAME with $A2UI_JSON_ARG_NAME") + + // Return the validated JSON so the converter can use it. + mapOf(VALIDATED_A2UI_JSON_KEY to a2uiJsonPayload) + } catch (e: Exception) { + val err = "Failed to call A2UI tool $TOOL_NAME: ${e.message}" + logger.severe(err) + mapOf(TOOL_ERROR_KEY to err) + } + } + } + + companion object { + /** Helper to create a toolset with constant values. */ + @JvmStatic + @JvmOverloads + fun create( + enabled: Boolean, + catalog: A2uiCatalog, + examples: String = "", + ): SendA2uiToClientToolset = SendA2uiToClientToolset({ enabled }, { catalog }, { examples }) + + const val TOOL_NAME = "send_a2ui_json_to_client" + const val VALIDATED_A2UI_JSON_KEY = "validated_a2ui_json" + private const val A2UI_JSON_ARG_NAME = "a2ui_json" + const val TOOL_ERROR_KEY = "error" + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt new file mode 100644 index 000000000..d27d13ea8 --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/basic_catalog/BasicCatalogProvider.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("BasicCatalogApi") + +package com.google.a2ui.basic_catalog + +import com.google.a2ui.core.schema.A2uiCatalogProvider +import com.google.a2ui.core.schema.A2uiConstants +import com.google.a2ui.core.schema.A2uiVersion +import com.google.a2ui.core.schema.CatalogConfig +import com.google.a2ui.core.schema.SchemaResourceLoader +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * A provider that loads A2UI JSON schemas and specifications from bundled package resources or + * local paths with established fallbacks. + * + * @param version The A2UI conceptual version, e.g., "0.9". + */ +class BundledCatalogProvider(private val version: A2uiVersion) : A2uiCatalogProvider { + + override fun load(): JsonObject { + val specMap = BasicCatalog.BASIC_CATALOG_PATHS[version] ?: emptyMap() + val relPath = specMap[A2uiConstants.CATALOG_SCHEMA_KEY] ?: "" + val filename = relPath.substringAfterLast('/') + + val resource = + SchemaResourceLoader.loadFromBundledResource(version.value, filename)?.toMutableMap() + ?: mutableMapOf() + + if (A2uiConstants.CATALOG_ID_KEY !in resource) { + specMap[A2uiConstants.CATALOG_SCHEMA_KEY]?.let { path -> + val catalogFile = path.replace("/json/", "/") + resource[A2uiConstants.CATALOG_ID_KEY] = + JsonPrimitive(A2uiConstants.BASE_SCHEMA_URL + catalogFile) + } + } + + if ("\$schema" !in resource) { + resource["\$schema"] = JsonPrimitive("https://json-schema.org/draft/2020-12/schema") + } + + return JsonObject(resource) + } +} + +/** Accessor for the built-in basic A2UI Catalog. */ +object BasicCatalog { + /** The standard identifier for the basic catalog. */ + const val BASIC_CATALOG_NAME = "basic" + + /** Paths to bundled standard catalogs for each spec version. */ + @JvmField + val BASIC_CATALOG_PATHS = + mapOf( + A2uiVersion.VERSION_0_8 to + mapOf( + A2uiConstants.CATALOG_SCHEMA_KEY to + "specification/v0_8/json/standard_catalog_definition.json" + ), + A2uiVersion.VERSION_0_9 to + mapOf(A2uiConstants.CATALOG_SCHEMA_KEY to "specification/v0_9/json/basic_catalog.json"), + ) + + /** + * Builds and returns a [CatalogConfig] customized for the basic A2UI catalog. Use this method + * from Java to easily instantiate catalog configurations. + * + * @param version The A2UI schema version. + * @param examplesPath An optional path string to load UI examples. + * @return A catalog configuration object defining how to load the basic schema. + */ + @JvmStatic + @JvmOverloads + fun getConfig(version: A2uiVersion, examplesPath: String? = null): CatalogConfig = + CatalogConfig( + name = BASIC_CATALOG_NAME, + provider = BundledCatalogProvider(version), + examplesPath = examplesPath, + ) +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/InferenceStrategy.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/InferenceStrategy.kt new file mode 100644 index 000000000..ce600136c --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/InferenceStrategy.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core + +/** + * Defines the contract for an A2UI inference strategy, responsible for constructing the LLM system + * prompt and providing schema/catalog-driven context to the agent. + */ +interface InferenceStrategy { + /** + * Generates a complete system prompt including the provided descriptions, A2UI JSON schema + * constraints, workflow guidelines, and examples. + * + * @param roleDescription The foundational role or persona for the agent. + * @param workflowDescription Optional workflow instructions to guide agent behavior. + * @param uiDescription Optional UI context or descriptive instruction. + * @param clientUiCapabilities Capabilities reported by the client for targeted schema pruning. + * @param allowedComponents A specific list of component IDs allowed for rendering. + * @param includeSchema Whether to embed the A2UI JSON schema directly in the instructions. + * @param includeExamples Whether to embed few-shot examples in the instructions. + * @param validateExamples Whether to preemptively validate loaded examples against the schema. + * @return A consolidated system prompt string. + */ + fun generateSystemPrompt( + roleDescription: String, + workflowDescription: String = "", + uiDescription: String = "", + clientUiCapabilities: kotlinx.serialization.json.JsonObject? = null, + allowedComponents: List = emptyList(), + includeSchema: Boolean = false, + includeExamples: Boolean = false, + validateExamples: Boolean = false, + ): String +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/Parser.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/Parser.kt new file mode 100644 index 000000000..77839228e --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/Parser.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.parser + +import com.google.a2ui.core.schema.A2uiConstants +import com.google.a2ui.core.schema.A2uiValidator +import java.util.logging.Logger +import kotlinx.serialization.json.JsonElement + +private val logger = Logger.getLogger("com.google.a2ui.core.parser.Parser") + +internal val A2UI_BLOCK_REGEX = + Regex( + "${A2uiConstants.A2UI_OPEN_TAG}(.*?)${A2uiConstants.A2UI_CLOSE_TAG}", + RegexOption.DOT_MATCHES_ALL, + ) + +/** Checks if the given text contains A2UI delimiter tags. */ +fun hasA2uiParts(text: String): Boolean = + text.contains(A2uiConstants.A2UI_OPEN_TAG) && text.contains(A2uiConstants.A2UI_CLOSE_TAG) + +/** Represents a part of the LLM response. */ +data class ResponsePart(val text: String, val a2uiJson: List? = null) + +/** Parses the response text into a list of ResponsePart objects. */ +fun parseResponseToParts(text: String, validator: A2uiValidator? = null): List { + val matches = A2UI_BLOCK_REGEX.findAll(text).toList() + + if (matches.isEmpty()) { + throw IllegalArgumentException( + "A2UI tags '${A2uiConstants.A2UI_OPEN_TAG}' and '${A2uiConstants.A2UI_CLOSE_TAG}' not found in response." + ) + } + + val responseParts = mutableListOf() + var lastEnd = 0 + + for (match in matches) { + val start = match.range.first + val end = match.range.last + 1 + val textPart = text.substring(lastEnd, start).trim() + + val jsonString = match.groupValues[1] + val jsonStringCleaned = sanitizeJsonString(jsonString) + + if (jsonStringCleaned.isEmpty()) { + throw IllegalArgumentException("A2UI JSON part is empty.") + } + + val elements = PayloadFixer.parseAndFix(jsonStringCleaned) + elements.forEach { validator?.validate(it) } + + responseParts.add(ResponsePart(text = textPart, a2uiJson = elements)) + lastEnd = end + } + + val trailingText = text.substring(lastEnd).trim() + if (trailingText.isNotEmpty()) { + responseParts.add(ResponsePart(text = trailingText, a2uiJson = null)) + } + + return responseParts +} + +/** Sanitize LLM output by removing markdown code blocks if present. */ +fun sanitizeJsonString(jsonString: String): String = + jsonString.trim().removePrefix("```json").removePrefix("```").removeSuffix("```").trim() diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/PayloadFixer.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/PayloadFixer.kt new file mode 100644 index 000000000..85aa68c6e --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/parser/PayloadFixer.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.parser + +import java.util.logging.Logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** Validates and applies autofixes to raw JSON strings. */ +object PayloadFixer { + + private val logger = Logger.getLogger(PayloadFixer::class.java.name) + + /** + * Parses and applies autofixes to a raw JSON string and returns the parsed payload as a list of + * components. + * + * @param payload The raw JSON string from the LLM. + * @return A parsed and potentially fixed payload (array of JsonElements). + */ + fun parseAndFix(payload: String): List { + val normalizedPayload = normalizeSmartQuotes(payload) + return try { + parse(normalizedPayload) + } catch (e: Exception) { + logger.warning("Initial A2UI payload validation failed: ${e.message}") + parse(removeTrailingCommas(normalizedPayload)) + } + } + + /** + * Replaces smart (curly) quotes with standard straight quotes. + * + * @param jsonStr The raw JSON string from the LLM. + * @return A string with curly quotes replaced by straight quotes. + */ + fun normalizeSmartQuotes(jsonStr: String): String { + return jsonStr + .replace('\u201C', '"') // “ + .replace('\u201D', '"') // ” + .replace('\u2018', '\'') // ‘ + .replace('\u2019', '\'') // ’ + } + + private fun parse(payload: String): List = + try { + when (val element = Json.parseToJsonElement(payload)) { + is JsonArray -> element.toList() + is JsonObject -> { + logger.info("Received a single JSON object, wrapping in a list for validation.") + listOf(element) + } + else -> + throw IllegalArgumentException( + "Payload must be a JSON Array or Object, got: ${element::class.simpleName}" + ) + } + } catch (e: Exception) { + logger.severe("Failed to parse JSON: ${e.message}") + throw IllegalArgumentException("Failed to parse JSON: ${e.message}", e) + } + + /** + * Attempts to remove trailing commas from a JSON string. + * + * This implementation is quote-aware and will not modify commas inside strings. + * + * @param jsonStr The raw JSON string from the LLM. + * @return A potentially fixed JSON string. + */ + fun removeTrailingCommas(jsonStr: String): String { + val result = StringBuilder() + var inString = false + var i = 0 + var lastCommaIndex = -1 + + while (i < jsonStr.length) { + val c = jsonStr[i] + + if (c == '"' && (i == 0 || jsonStr[i - 1] != '\\')) { + inString = !inString + } + + if (!inString) { + when { + c == ',' -> lastCommaIndex = result.length + (c == ']' || c == '}') && lastCommaIndex != -1 -> { + val contentBetween = result.substring(lastCommaIndex + 1) + if (contentBetween.isBlank()) { + result.deleteCharAt(lastCommaIndex) + } + lastCommaIndex = -1 + } + !c.isWhitespace() -> lastCommaIndex = -1 + } + } + + result.append(c) + i++ + } + + val fixedJson = result.toString() + if (fixedJson != jsonStr) { + logger.warning("Detected trailing commas in LLM output; applied robust autofix.") + } + return fixedJson + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiConstants.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiConstants.kt new file mode 100644 index 000000000..1c944b7d6 --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiConstants.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +/** Central repository for A2UI protocol constants, aligned with the Python SDK. */ +object A2uiConstants { + const val A2UI_ASSET_PACKAGE = "com.google.a2ui.assets" + const val SERVER_TO_CLIENT_SCHEMA_KEY = "server_to_client" + const val COMMON_TYPES_SCHEMA_KEY = "common_types" + const val CATALOG_SCHEMA_KEY = "catalog" + const val CATALOG_COMPONENTS_KEY = "components" + const val CATALOG_ID_KEY = "catalogId" + const val CATALOG_STYLES_KEY = "styles" + + // Protocol constants + const val SUPPORTED_CATALOG_IDS_KEY = "supportedCatalogIds" + const val INLINE_CATALOGS_KEY = "inlineCatalogs" + + const val A2UI_CLIENT_CAPABILITIES_KEY = "a2uiClientCapabilities" + + const val BASE_SCHEMA_URL = "https://a2ui.org/" + const val INLINE_CATALOG_NAME = "inline" + + const val VERSION_0_8 = "0.8" + const val VERSION_0_9 = "0.9" + + const val A2UI_OPEN_TAG = "" + const val A2UI_CLOSE_TAG = "" + + const val A2UI_SCHEMA_BLOCK_START = "---BEGIN A2UI JSON SCHEMA---" + const val A2UI_SCHEMA_BLOCK_END = "---END A2UI JSON SCHEMA---" + + const val DEFAULT_WORKFLOW_RULES = + """ + The generated response MUST follow these rules: + 1. The response can contain one or more A2UI JSON blocks. + 2. Each A2UI JSON block MUST be wrapped in $A2UI_OPEN_TAG and $A2UI_CLOSE_TAG tags. + 3. Between or around these blocks, you can provide conversational text. + 4. The JSON part MUST be a single, raw JSON object (usually a list of A2UI messages) and MUST validate against the provided A2UI JSON SCHEMA. + """ +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiSchemaManager.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiSchemaManager.kt new file mode 100644 index 000000000..c9ef6d74e --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/A2uiSchemaManager.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import com.google.a2ui.core.InferenceStrategy +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** Represents the supported A2UI specification versions. */ +enum class A2uiVersion( + val value: String, + val serverToClientSchemaPath: String, + val commonTypesSchemaPath: String? = null, +) { + VERSION_0_8(A2uiConstants.VERSION_0_8, "server_to_client.json"), + VERSION_0_9(A2uiConstants.VERSION_0_9, "server_to_client.json", "common_types.json"), +} + +/** + * Integrates A2UI capabilities by processing and aggregating [CatalogConfig] schemas to establish + * the grammatical constraints for Agent UI payload generation. + * + * This implementation of [InferenceStrategy] synthesizes the resulting system prompt instructions, + * which contain the precise JSON grammar for the LLM based on the active schemas and requested + * components negotiated between the agent and client. + * + * @param version Framework version (e.g., "0.8" or "0.9"). Delineates internal topology handling + * and resolution strategies. + * @param catalogs List of user-configured catalogs offering UI components to this agent. + * @param acceptsInlineCatalogs Whether this agent permits the client UI to dictate dynamic schema + * payloads rather than relying solely on bundled configurations. + * @param schemaModifiers An optional chain of transformations to perform on all resolved schema + * endpoints. + */ +class A2uiSchemaManager +@JvmOverloads +constructor( + private val version: A2uiVersion, + catalogs: List = emptyList(), + @JvmField val acceptsInlineCatalogs: Boolean = false, + private val schemaModifiers: List<(JsonObject) -> JsonObject> = emptyList(), +) : InferenceStrategy { + + private val serverToClientSchema: JsonObject + private val commonTypesSchema: JsonObject + private val supportedCatalogs = mutableListOf() + private val catalogExamplePaths = mutableMapOf() + + /** + * Identifies the catalogs technically supported by this running agent application. Can be + * utilized to negotiate payload rendering with downstream clients. + */ + val supportedCatalogIds: List + get() = supportedCatalogs.map { it.catalogId } + + init { + serverToClientSchema = + applyModifiers( + SchemaResourceLoader.loadFromBundledResource( + version.value, + version.serverToClientSchemaPath, + ) ?: JsonObject(emptyMap()) + ) + + commonTypesSchema = + version.commonTypesSchemaPath?.let { + applyModifiers( + SchemaResourceLoader.loadFromBundledResource(version.value, it) ?: JsonObject(emptyMap()) + ) + } ?: JsonObject(emptyMap()) + + for (config in catalogs) { + val catalogSchema = applyModifiers(config.provider.load()) + val catalog = + A2uiCatalog( + version = version, + name = config.name, + catalogSchema = catalogSchema, + serverToClientSchema = serverToClientSchema, + commonTypesSchema = commonTypesSchema, + ) + supportedCatalogs.add(catalog) + catalogExamplePaths[catalog.catalogId] = config.examplesPath + } + } + + private fun applyModifiers(schema: JsonObject): JsonObject = + schemaModifiers.fold(schema) { current, modifier -> modifier(current) } + + private fun selectCatalog(clientUiCapabilities: JsonObject?): A2uiCatalog { + check(supportedCatalogs.isNotEmpty()) { "No supported catalogs found." } + + if (clientUiCapabilities == null) return supportedCatalogs.first() + + val inlineCatalogs = + (clientUiCapabilities[A2uiConstants.INLINE_CATALOGS_KEY] as? JsonArray)?.mapNotNull { + it as? JsonObject + } ?: emptyList() + + val clientSupportedCatalogIds = + (clientUiCapabilities[A2uiConstants.SUPPORTED_CATALOG_IDS_KEY] as? JsonArray)?.mapNotNull { + it.jsonPrimitive.content + } ?: emptyList() + + if (!acceptsInlineCatalogs && inlineCatalogs.isNotEmpty()) { + throw IllegalArgumentException( + "Inline catalog '${A2uiConstants.INLINE_CATALOGS_KEY}' is provided in client UI capabilities. However, the agent does not accept inline catalogs." + ) + } + + if (inlineCatalogs.isNotEmpty()) { + // Determine the base catalog: use supportedCatalogIds if provided, + // otherwise fall back to the agent's default catalog. + var baseCatalog = supportedCatalogs.first() + if (clientSupportedCatalogIds.isNotEmpty()) { + val agentSupportedCatalogs = supportedCatalogs.associateBy { it.catalogId } + for (cscid in clientSupportedCatalogIds) { + agentSupportedCatalogs[cscid]?.let { baseCatalog = it } + if (baseCatalog != supportedCatalogs.first()) break + } + } + + val mergedSchemaMap = baseCatalog.catalogSchema.toMutableMap() + val mergedComponents = + mergedSchemaMap[A2uiConstants.CATALOG_COMPONENTS_KEY]?.jsonObject?.toMutableMap() + ?: mutableMapOf() + + for (inlineCatalogSchema in inlineCatalogs) { + val modifiedInline = applyModifiers(inlineCatalogSchema) + val inlineComponents = + modifiedInline[A2uiConstants.CATALOG_COMPONENTS_KEY]?.jsonObject ?: JsonObject(emptyMap()) + mergedComponents.putAll(inlineComponents) + } + + mergedSchemaMap[A2uiConstants.CATALOG_COMPONENTS_KEY] = JsonObject(mergedComponents) + val mergedSchema = JsonObject(mergedSchemaMap) + + return A2uiCatalog( + version = version, + name = A2uiConstants.INLINE_CATALOG_NAME, + catalogSchema = mergedSchema, + serverToClientSchema = serverToClientSchema, + commonTypesSchema = commonTypesSchema, + ) + } + + if (clientSupportedCatalogIds.isEmpty()) return supportedCatalogs.first() + + val agentSupportedCatalogs = supportedCatalogs.associateBy { it.catalogId } + for (cscid in clientSupportedCatalogIds) { + agentSupportedCatalogs[cscid]?.let { + return it + } + } + + throw IllegalArgumentException( + "No client-supported catalog found on the agent side. Agent-supported catalogs are: ${supportedCatalogs.map { it.catalogId }}" + ) + } + + /** + * Resolves the desired catalog based on the client capabilities, returning it with pruned unused + * components. + */ + @JvmOverloads + fun getSelectedCatalog( + clientUiCapabilities: JsonObject? = null, + allowedComponents: List = emptyList(), + ): A2uiCatalog = selectCatalog(clientUiCapabilities).withPrunedComponents(allowedComponents) + + /** Renders LLM examples for a given catalog, loaded from its configured examples path. */ + @JvmOverloads + fun loadExamples(catalog: A2uiCatalog, validate: Boolean = false): String = + catalogExamplePaths[catalog.catalogId]?.let { path -> catalog.loadExamples(path, validate) } + ?: "" + + /** Creates a fully formatted system prompt describing the schema to the LLM model. */ + override fun generateSystemPrompt( + roleDescription: String, + workflowDescription: String, + uiDescription: String, + clientUiCapabilities: JsonObject?, + allowedComponents: List, + includeSchema: Boolean, + includeExamples: Boolean, + validateExamples: Boolean, + ): String { + val parts = mutableListOf(roleDescription) + + val workflow = + if (workflowDescription.isEmpty()) A2uiConstants.DEFAULT_WORKFLOW_RULES + else "${A2uiConstants.DEFAULT_WORKFLOW_RULES}\n$workflowDescription" + parts.add("## Workflow Description:\n$workflow") + + if (uiDescription.isNotEmpty()) { + parts.add("## UI Description:\n$uiDescription") + } + + val selectedCatalog = getSelectedCatalog(clientUiCapabilities, allowedComponents) + + if (includeSchema) { + parts.add(selectedCatalog.renderAsLlmInstructions()) + } + + if (includeExamples) { + val examplesStr = loadExamples(selectedCatalog, validateExamples) + if (examplesStr.isNotEmpty()) { + parts.add("### Examples:\n$examplesStr") + } + } + + return parts.joinToString("\n\n") + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt new file mode 100644 index 000000000..b499934db --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Catalog.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("CatalogApi") + +package com.google.a2ui.core.schema + +import java.io.File +import java.util.logging.Logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +/** + * Configuration for a catalog of components. + * + * A catalog consists of a provider that knows how to load the schema, and optionally a path to + * examples. + */ +data class CatalogConfig( + @JvmField val name: String, + @JvmField val provider: A2uiCatalogProvider, + @JvmField val examplesPath: String? = null, +) { + companion object { + /** Create a [CatalogConfig] using a [FileSystemCatalogProvider]. */ + @JvmStatic + @JvmOverloads + fun fromPath(name: String, catalogPath: String, examplesPath: String? = null): CatalogConfig = + CatalogConfig(name, FileSystemCatalogProvider(catalogPath), examplesPath) + } +} + +/** Represents a processed component catalog with its schema. */ +data class A2uiCatalog( + @JvmField val version: A2uiVersion, + @JvmField val name: String, + @JvmField val serverToClientSchema: JsonObject, + @JvmField val commonTypesSchema: JsonObject, + @JvmField val catalogSchema: JsonObject, +) { + + private val logger = Logger.getLogger(A2uiCatalog::class.java.name) + + val validator: A2uiValidator by lazy { A2uiValidator(this) } + + val catalogId: String + get() { + val idElement = catalogSchema[A2uiConstants.CATALOG_ID_KEY] + require(idElement is JsonPrimitive && idElement.isString) { + "Catalog '$name' missing catalogId" + } + return idElement.content + } + + /** + * Returns a new catalog with only allowed components. + * + * @param allowedComponents List of component names to include. + * @return A copy of the catalog with only allowed components. + */ + fun withPrunedComponents(allowedComponents: List): A2uiCatalog { + if (allowedComponents.isEmpty()) return this + + val schemaCopy = catalogSchema.toMutableMap() + + // Filter components listing + (schemaCopy[A2uiConstants.CATALOG_COMPONENTS_KEY] as? JsonObject)?.let { components -> + schemaCopy[A2uiConstants.CATALOG_COMPONENTS_KEY] = + JsonObject(components.filterKeys { it in allowedComponents }) + } + + // Filter anyComponent oneOf if it exists + (schemaCopy["\$defs"] as? JsonObject)?.let { defsElement -> + (defsElement["anyComponent"] as? JsonObject)?.let { anyCompElement -> + val newAnyComp = pruneAnyComponentOneOf(anyCompElement, allowedComponents) + val newDefs = defsElement.toMutableMap().apply { put("anyComponent", newAnyComp) } + schemaCopy["\$defs"] = JsonObject(newDefs) + } + } + + return copy(catalogSchema = JsonObject(schemaCopy)) + } + + private fun pruneAnyComponentOneOf( + anyCompElement: JsonObject, + allowedComponents: List, + ): JsonObject { + val oneOfElement = anyCompElement["oneOf"] as? JsonArray ?: return anyCompElement + + val filteredOneOf = + oneOfElement.filter { item -> + val ref = (item as? JsonObject)?.get("\$ref")?.jsonPrimitive?.content + if (ref != null && ref.startsWith("#/${A2uiConstants.CATALOG_COMPONENTS_KEY}/")) { + val compName = ref.split("/").last() + compName in allowedComponents + } else { + true // Keep external refs or non-matching refs + } + } + + return JsonObject(anyCompElement + ("oneOf" to JsonArray(filteredOneOf))) + } + + /** Renders the catalog and schema as LLM instructions. */ + fun renderAsLlmInstructions(): String = buildString { + appendLine(A2uiConstants.A2UI_SCHEMA_BLOCK_START) + val jsonFmt = Json + + appendLine("### Server To Client Schema:") + appendLine(jsonFmt.encodeToString(JsonElement.serializer(), serverToClientSchema)) + + if (commonTypesSchema.isNotEmpty()) { + appendLine("\n### Common Types Schema:") + appendLine(jsonFmt.encodeToString(JsonElement.serializer(), commonTypesSchema)) + } + + appendLine("\n### Catalog Schema:") + appendLine(jsonFmt.encodeToString(JsonElement.serializer(), catalogSchema)) + + append("\n${A2uiConstants.A2UI_SCHEMA_BLOCK_END}") + } + + /** Loads and validates examples from a directory. */ + @JvmOverloads + fun loadExamples(path: String?, validate: Boolean = false): String { + if (path.isNullOrEmpty()) return "" + val dir = File(path) + if (!dir.isDirectory) { + logger.warning("Example path $path is not a directory") + return "" + } + + val files = dir.listFiles { _, name -> name.endsWith(".json") } ?: emptyArray() + + return files + .mapNotNull { file -> + val basename = file.nameWithoutExtension + try { + val content = file.readText() + if (validate && !validateExample(file.path, content)) { + null + } else { + "---BEGIN $basename---\n$content\n---END $basename---" + } + } catch (e: Exception) { + logger.warning("Failed to load example ${file.path}: ${e.message}") + null + } + } + .joinToString("\n\n") + } + + private fun validateExample(fullPath: String, content: String): Boolean = + try { + val jsonElement = Json.parseToJsonElement(content) + validator.validate(jsonElement) + true + } catch (e: Exception) { + logger.warning("Failed to validate example $fullPath: ${e.message}") + false + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt new file mode 100644 index 000000000..26f0e1e7a --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/CatalogProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import java.io.File +import java.io.IOException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +/** Abstract base interface for providing A2UI schemas and catalogs. */ +interface A2uiCatalogProvider { + /** + * Loads a catalog definition. + * + * @return The loaded catalog as a JsonObject. + */ + fun load(): JsonObject +} + +/** Loads catalog definition from the local filesystem. */ +class FileSystemCatalogProvider(private val path: String) : A2uiCatalogProvider { + override fun load(): JsonObject { + try { + val file = File(path) + val content = file.readText(Charsets.UTF_8) + return Json.parseToJsonElement(content) as JsonObject + } catch (e: Exception) { + throw IOException("Could not load schema from ${path}: ${e.message}", e) + } + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaModifiers.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaModifiers.kt new file mode 100644 index 000000000..bfd2a2c4f --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaModifiers.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** Standard transformations for A2UI schemas. */ +object SchemaModifiers { + /** + * Recursively removes "additionalProperties: false" constraints from a JSON schema. + * + * This is useful when the agent might generate slightly different properties than defined in a + * strict schema, allowing for more flexible LLM output. + */ + fun removeStrictValidation(schema: JsonObject): JsonObject = + recursiveRemoveStrict(schema) as JsonObject + + private fun recursiveRemoveStrict(element: JsonElement): JsonElement = + when (element) { + is JsonObject -> { + val filtered = + element.filter { (key, value) -> + key != "additionalProperties" || (value as? JsonPrimitive)?.booleanOrNull != false + } + JsonObject(filtered.mapValues { recursiveRemoveStrict(it.value) }) + } + is JsonArray -> JsonArray(element.map { recursiveRemoveStrict(it) }) + else -> element + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaResourceLoader.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaResourceLoader.kt new file mode 100644 index 000000000..d9f8cf42c --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/SchemaResourceLoader.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("A2uiUtils") + +package com.google.a2ui.core.schema + +import java.io.IOException +import java.io.InputStream +import java.util.logging.Logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Common utilities for loading schemas and preparing JSON data. */ +object SchemaResourceLoader { + const val A2UI_ASSET_PACKAGE = A2uiConstants.A2UI_ASSET_PACKAGE + private val logger = Logger.getLogger(SchemaResourceLoader::class.java.name) + + /** Loads a JSON schema from bundled resources or file system. */ + @JvmStatic + fun loadFromBundledResource(version: String, filename: String): JsonObject? { + // Try to load from the bundled package resources + val resourcePath = "/${A2UI_ASSET_PACKAGE.replace('.', '/')}/$version/$filename" + return try { + val stream: InputStream? = SchemaResourceLoader::class.java.getResourceAsStream(resourcePath) + stream?.bufferedReader()?.use { Json.parseToJsonElement(it.readText()) as JsonObject } + ?: run { + logger.fine("Could not find system resource $resourcePath") + throw IOException("Could not load schema $filename for version $version") + } + } catch (e: Exception) { + logger.fine("Could not load '$filename' from package resources: ${e.message}") + throw IOException("Could not load schema $filename for version $version", e) + } + } + + /** + * LLM is instructed to generate a list of messages, so we wrap the bundled schema in an array. + */ + @JvmStatic + fun wrapAsJsonArray(a2uiSchema: JsonObject): JsonObject { + require(a2uiSchema.isNotEmpty()) { "A2UI schema is empty" } + return JsonObject(mapOf("type" to JsonPrimitive("array"), "items" to a2uiSchema)) + } +} diff --git a/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt new file mode 100644 index 000000000..209e2571b --- /dev/null +++ b/agent_sdks/kotlin/src/main/kotlin/com/google/a2ui/core/schema/Validator.kt @@ -0,0 +1,589 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.JsonSchema +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SchemaValidatorsConfig +import com.networknt.schema.SpecVersion +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * Responsible for verifying the structural and topological integrity of an A2UI payload. + * + * It utilizes the active [A2uiCatalog] to construct a context-aware JSON schema validator, and + * performs advanced integrity checks including depth-limits, graph topology validations, and + * reference resolutions for rendering capabilities. + * + * @param catalog The localized contextual A2UI catalog utilized for schema validation. + */ +class A2uiValidator(private val catalog: A2uiCatalog) { + private val validator: JsonSchema = buildValidator() + private val mapper = ObjectMapper() + + private fun buildValidator(): JsonSchema = + if (catalog.version == A2uiVersion.VERSION_0_8) build0_8Validator() else build0_9Validator() + + private fun injectAdditionalProperties( + schema: JsonElement, + sourceProperties: Map, + ): Pair> { + val injectedKeys = mutableSetOf() + + fun recursiveInject(obj: JsonElement): JsonElement = + when (obj) { + is JsonObject -> { + val newObj = mutableMapOf() + for ((k, v) in obj) { + if ( + v is JsonObject && v[PROP_ADDITIONAL_PROPERTIES]?.jsonPrimitive?.booleanOrNull == true + ) { + if (sourceProperties.containsKey(k)) { + injectedKeys.add(k) + val newNode = v.toMutableMap() + newNode[PROP_ADDITIONAL_PROPERTIES] = JsonPrimitive(false) + + val existingProps = + newNode[PROP_PROPERTIES] as? JsonObject ?: JsonObject(emptyMap()) + val sourceProps = sourceProperties[k] as? JsonObject ?: JsonObject(emptyMap()) + + newNode[PROP_PROPERTIES] = JsonObject(existingProps + sourceProps) + newObj[k] = JsonObject(newNode) + } else { + newObj[k] = recursiveInject(v) + } + } else { + newObj[k] = recursiveInject(v) + } + } + JsonObject(newObj) + } + is JsonArray -> JsonArray(obj.map { recursiveInject(it) }) + else -> obj + } + + return recursiveInject(schema) to injectedKeys + } + + private fun bundle0_8Schemas(): JsonObject { + if (catalog.serverToClientSchema.isEmpty()) return JsonObject(emptyMap()) + + val sourceProperties = mutableMapOf() + val catalogSchema = catalog.catalogSchema + + if (catalogSchema.isNotEmpty()) { + catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY]?.let { + sourceProperties[PROP_COMPONENT] = it + } + catalogSchema[A2uiConstants.CATALOG_STYLES_KEY]?.let { + sourceProperties[A2uiConstants.CATALOG_STYLES_KEY] = it + } + } + + val (bundled) = injectAdditionalProperties(catalog.serverToClientSchema, sourceProperties) + return bundled as JsonObject + } + + private fun build0_8Validator(): JsonSchema { + val bundledSchema = bundle0_8Schemas() + val fullSchema = SchemaResourceLoader.wrapAsJsonArray(bundledSchema).toMutableMap() + fullSchema[KEY_DOLLAR_SCHEMA] = JsonPrimitive(SCHEMA_DRAFT_2020_12) + + val baseUri = + catalog.serverToClientSchema[KEY_DOLLAR_ID]?.jsonPrimitive?.content + ?: A2uiConstants.BASE_SCHEMA_URL + val baseDirUri = baseUri.substringBeforeLast("/") + val commonTypesUri = "$baseDirUri/$FILE_COMMON_TYPES" + + val config = SchemaValidatorsConfig.builder().build() + + val jsonFmt = Json { prettyPrint = false } + + val factory = + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)) + .schemaMappers { schemaMappers -> + schemaMappers.mapPrefix(FILE_COMMON_TYPES, commonTypesUri) + } + .build() + + val schemaString = jsonFmt.encodeToString(JsonElement.serializer(), JsonObject(fullSchema)) + return factory.getSchema(schemaString, config) + } + + private fun build0_9Validator(): JsonSchema { + val fullSchema = + SchemaResourceLoader.wrapAsJsonArray(catalog.serverToClientSchema).toMutableMap() + fullSchema[KEY_DOLLAR_SCHEMA] = JsonPrimitive(SCHEMA_DRAFT_2020_12) + + val config = SchemaValidatorsConfig.builder().build() + val factory = + JsonSchemaFactory.builder(JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)) + .build() + + val jsonFmt = Json { prettyPrint = false } + val schemaString = jsonFmt.encodeToString(JsonElement.serializer(), JsonObject(fullSchema)) + return factory.getSchema(schemaString, config) + } + + /** + * Parses and validates raw A2UI JSON payload against the designated schema, throwing an + * [IllegalArgumentException] describing any constraint or structural invalidity. + * + * It asserts fundamental strict JSON schemas, recursive depth boundaries, and component reference + * integrity. + * + * @param a2uiJson Raw parsed A2UI response payload element to inspect. + * @throws IllegalArgumentException If validation or referential integrity fail. + */ + fun validate(a2uiJson: JsonElement) { + val messages = a2uiJson as? JsonArray ?: JsonArray(listOf(a2uiJson)) + + // Basic schema validation + val jsonFmt = Json { prettyPrint = false } + val messagesString = jsonFmt.encodeToString(JsonElement.serializer(), messages) + val jsonNode = mapper.readTree(messagesString) + + val errors = validator.validate(jsonNode) + if (errors.isNotEmpty()) { + val msg = buildString { + append("Validation failed:") + for (error in errors) { + append("\n - ${error.message}") + } + } + throw IllegalArgumentException(msg) + } + + // Integrity validation + val rootId = findRootId(messages) + val topologyValidator = A2uiTopologyValidator(catalog, rootId) + val recursionValidator = A2uiRecursionValidator() + + for (message in messages) { + if (message !is JsonObject) continue + + val components = + when { + MSG_SURFACE_UPDATE in message -> + (message[MSG_SURFACE_UPDATE] as? JsonObject)?.get(A2uiConstants.CATALOG_COMPONENTS_KEY) + as? JsonArray + MSG_UPDATE_COMPONENTS in message -> + (message[MSG_UPDATE_COMPONENTS] as? JsonObject)?.get( + A2uiConstants.CATALOG_COMPONENTS_KEY + ) as? JsonArray + else -> null + } + + components?.let { topologyValidator.validate(it) } + + recursionValidator.validate(message) + } + } + + private fun findRootId(messages: JsonArray): String { + for (message in messages) { + if (message is JsonObject && MSG_BEGIN_RENDERING in message) { + val beginRendering = message[MSG_BEGIN_RENDERING] as? JsonObject + val rootObj = beginRendering?.get(ROOT) as? JsonObject + return rootObj?.get(ID)?.jsonPrimitive?.content ?: ROOT + } + } + return ROOT + } + + /** Validates component graph topology, including cycles, orphans, and missing references. */ + private class A2uiTopologyValidator( + private val catalog: A2uiCatalog, + private val rootId: String, + ) { + + fun validate(components: JsonArray) { + val refFieldsMap = extractComponentRefFields() + validateComponentIntegrity(components, refFieldsMap) + validateTopology(components, refFieldsMap) + } + + /** + * Analyzes the catalog schema to identify which fields in each component type act as references + * to other components (either single IDs or lists of IDs). + * + * @return A map where the key is the component type name, and the value is a Pair of: + * - Set of property names that are single component references. + * - Set of property names that are list/collection component references. + */ + private fun extractComponentRefFields(): Map, Set>> { + val refMap = mutableMapOf, Set>>() + + val allComponents = + catalog.catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY] as? JsonObject ?: return refMap + + // Heuristically determines if a schema property represents a single ComponentId reference. + fun isComponentIdRef(propSchema: JsonElement): Boolean { + if (propSchema !is JsonObject) return false + val ref = propSchema[KEY_DOLLAR_REF]?.jsonPrimitive?.content ?: "" + if (ref.endsWith(TITLE_COMPONENT_ID) || ref.endsWith(PROP_CHILD) || "/$PROP_CHILD" in ref) + return true + + if ( + propSchema[PROP_TYPE]?.jsonPrimitive?.content == TYPE_STRING && + propSchema[PROP_TITLE]?.jsonPrimitive?.content == TITLE_COMPONENT_ID + ) { + return true + } + + return listOf(COMBINATOR_ONE_OF, COMBINATOR_ANY_OF, COMBINATOR_ALL_OF).any { key -> + (propSchema[key] as? JsonArray)?.any { isComponentIdRef(it) } == true + } + } + + // Heuristically determines if a schema property represents a collection of ComponentIds. + fun isChildListRef(propSchema: JsonElement): Boolean { + if (propSchema !is JsonObject) return false + val ref = propSchema[KEY_DOLLAR_REF]?.jsonPrimitive?.content ?: "" + if ( + ref.endsWith(TITLE_CHILD_LIST) || ref.endsWith(PROP_CHILDREN) || "/$PROP_CHILDREN" in ref + ) + return true + + if (propSchema[PROP_TYPE]?.jsonPrimitive?.content == TYPE_OBJECT) { + val props = propSchema[PROP_PROPERTIES] as? JsonObject + if ( + props != null && + (PROP_EXPLICIT_LIST in props || PROP_TEMPLATE in props || PROP_COMPONENT_ID in props) + ) { + return true + } + } + + if (propSchema[PROP_TYPE]?.jsonPrimitive?.content == TYPE_ARRAY) { + val items = propSchema[PROP_ITEMS] + if (items != null && isComponentIdRef(items)) return true + } + + return listOf(COMBINATOR_ONE_OF, COMBINATOR_ANY_OF, COMBINATOR_ALL_OF).any { key -> + (propSchema[key] as? JsonArray)?.any { isChildListRef(it) } == true + } + } + + // Iterate over all components defined in the catalog to extract their reference fields. + for ((compName, compSchemaElem) in allComponents) { + val singleRefs = mutableSetOf() + val listRefs = mutableSetOf() + + // Recursively inspects properties and combinators to find reference fields. + fun extractFromProps(cs: JsonElement) { + if (cs !is JsonObject) return + val props = cs[PROP_PROPERTIES] as? JsonObject ?: return + for ((propName, propSchema) in props) { + if ( + isComponentIdRef(propSchema) || + propName in listOf(PROP_CHILD, PROP_CONTENT_CHILD, PROP_ENTRY_POINT_CHILD) + ) { + singleRefs.add(propName) + } else if (isChildListRef(propSchema) || propName == PROP_CHILDREN) { + listRefs.add(propName) + } + } + + listOf(COMBINATOR_ALL_OF, COMBINATOR_ONE_OF, COMBINATOR_ANY_OF).forEach { key -> + (cs[key] as? JsonArray)?.forEach { extractFromProps(it) } + } + } + + extractFromProps(compSchemaElem) + if (singleRefs.isNotEmpty() || listRefs.isNotEmpty()) { + refMap[compName] = singleRefs to listRefs + } + } + return refMap + } + + private fun validateComponentIntegrity( + components: JsonArray, + refFieldsMap: Map, Set>>, + ) { + val ids = mutableSetOf() + + for (compElem in components) { + val comp = compElem as? JsonObject ?: continue + val compId = comp[ID]?.jsonPrimitive?.content ?: continue + + if (!ids.add(compId)) { + throw IllegalArgumentException("Duplicate component ID: $compId") + } + } + + if (rootId !in ids) { + throw IllegalArgumentException("Missing root component: No component has id='$rootId'") + } + + for (compElem in components) { + val comp = compElem as? JsonObject ?: continue + for ((refId, fieldName) in getComponentReferences(comp, refFieldsMap)) { + if (refId !in ids) { + val cId = comp[ID]?.jsonPrimitive?.content + throw IllegalArgumentException( + "Component '$cId' references non-existent component '$refId' in field '$fieldName'" + ) + } + } + } + } + + private fun validateTopology( + components: JsonArray, + refFieldsMap: Map, Set>>, + ) { + val adjList = mutableMapOf>() + val allIds = mutableSetOf() + + for (compElem in components) { + val comp = compElem as? JsonObject ?: continue + val compId = comp[ID]?.jsonPrimitive?.content ?: continue + + allIds.add(compId) + val neighbors = adjList.getOrPut(compId) { mutableListOf() } + + for ((refId, fieldName) in getComponentReferences(comp, refFieldsMap)) { + if (refId == compId) { + throw IllegalArgumentException( + "Self-reference detected: Component '$compId' references itself in field '$fieldName'" + ) + } + neighbors.add(refId) + } + } + + val visited = mutableSetOf() + val recursionStack = mutableSetOf() + + fun dfs(nodeId: String, depth: Int) { + if (depth > MAX_GLOBAL_DEPTH) { + throw IllegalArgumentException( + "Global recursion limit exceeded: logical depth > $MAX_GLOBAL_DEPTH" + ) + } + + visited.add(nodeId) + recursionStack.add(nodeId) + + for (neighbor in adjList[nodeId] ?: emptyList()) { + if (neighbor !in visited) { + dfs(neighbor, depth + 1) + } else if (neighbor in recursionStack) { + throw IllegalArgumentException( + "Circular reference detected involving component '$neighbor'" + ) + } + } + + recursionStack.remove(nodeId) + } + + if (rootId in allIds) dfs(rootId, 0) + + val orphans = allIds - visited + if (orphans.isNotEmpty()) { + val firstOrphan = orphans.sorted().first() + throw IllegalArgumentException("Component '$firstOrphan' is not reachable from '$rootId'") + } + } + + private fun getComponentReferences( + component: JsonObject, + refFieldsMap: Map, Set>>, + ): Sequence> = sequence { + if (PROP_COMPONENT in component) { + when (val compVal = component[PROP_COMPONENT]) { + is JsonPrimitive -> { + if (compVal.isString) + yieldAll(getRefsRecursively(compVal.content, component, refFieldsMap)) + } + is JsonObject -> { + for ((cType, cProps) in compVal) { + if (cProps is JsonObject) { + yieldAll(getRefsRecursively(cType, cProps, refFieldsMap)) + } + } + } + else -> {} + } + } + } + + private fun getRefsRecursively( + compType: String, + props: JsonObject, + refFieldsMap: Map, Set>>, + ): Sequence> = sequence { + val (singleRefs, listRefs) = refFieldsMap[compType] ?: (emptySet() to emptySet()) + + for ((key, value) in props) { + when { + key in singleRefs -> { + when { + value is JsonPrimitive && value.isString -> yield(value.content to key) + value is JsonObject && PROP_COMPONENT_ID in value -> { + value[PROP_COMPONENT_ID]?.jsonPrimitive?.content?.let { + yield(it to "$key.$PROP_COMPONENT_ID") + } + } + } + } + key in listRefs -> { + when (value) { + is JsonArray -> { + for (item in value) { + if (item is JsonPrimitive && item.isString) yield(item.content to key) + } + } + is JsonObject -> { + when { + PROP_EXPLICIT_LIST in value -> { + (value[PROP_EXPLICIT_LIST] as? JsonArray)?.forEach { item -> + if (item is JsonPrimitive && item.isString) + yield(item.content to "$key.$PROP_EXPLICIT_LIST") + } + } + PROP_TEMPLATE in value -> { + val template = value[PROP_TEMPLATE] as? JsonObject + template?.get(PROP_COMPONENT_ID)?.jsonPrimitive?.content?.let { + yield(it to "$key.$PROP_TEMPLATE.$PROP_COMPONENT_ID") + } + } + PROP_COMPONENT_ID in value -> { + value[PROP_COMPONENT_ID]?.jsonPrimitive?.content?.let { + yield(it to "$key.$PROP_COMPONENT_ID") + } + } + } + } + else -> {} + } + } + value is JsonArray -> { + for ((idx, item) in value.withIndex()) { + if (item is JsonObject) { + item[PROP_CHILD]?.jsonPrimitive?.content?.let { + yield(it to "$key[$idx].$PROP_CHILD") + } + } + } + } + } + } + } + } + + /** Validates JSON payload recursion depth and functional call depth. */ + private class A2uiRecursionValidator { + fun validate(data: JsonElement) = traverse(data, 0, 0) + + private fun traverse(item: JsonElement, globalDepth: Int, funcDepth: Int) { + if (globalDepth > MAX_GLOBAL_DEPTH) { + throw IllegalArgumentException("Global recursion limit exceeded: Depth > $MAX_GLOBAL_DEPTH") + } + + when (item) { + is JsonArray -> item.forEach { traverse(it, globalDepth + 1, funcDepth) } + is JsonObject -> { + (item[PATH] as? JsonPrimitive) + ?.takeIf { it.isString } + ?.let { pathElem -> + if (!pathElem.content.matches(JSON_POINTER_PATTERN)) { + throw IllegalArgumentException("Invalid JSON Pointer syntax: '${pathElem.content}'") + } + } + + val isFunc = CALL in item && ARGS in item + if (isFunc) { + if (funcDepth >= MAX_FUNC_CALL_DEPTH) { + throw IllegalArgumentException( + "Recursion limit exceeded: $FUNCTION_CALL depth > $MAX_FUNC_CALL_DEPTH" + ) + } + for ((k, v) in item) { + val nextFuncDepth = if (k == ARGS) funcDepth + 1 else funcDepth + traverse(v, globalDepth + 1, nextFuncDepth) + } + } else { + item.values.forEach { traverse(it, globalDepth + 1, funcDepth) } + } + } + else -> {} + } + } + } + + private companion object { + private val JSON_POINTER_PATTERN = Regex("^(?:/(?:[^~/]|~[01])*)*$") + private const val MAX_GLOBAL_DEPTH = 50 + private const val MAX_FUNC_CALL_DEPTH = 5 + + private const val ROOT = "root" + private const val ID = "id" + private const val PATH = "path" + private const val FUNCTION_CALL = "functionCall" + private const val CALL = "call" + private const val ARGS = "args" + + private const val MSG_SURFACE_UPDATE = "surfaceUpdate" + private const val MSG_UPDATE_COMPONENTS = "updateComponents" + private const val MSG_BEGIN_RENDERING = "beginRendering" + + // JSON Schema standard keys + private const val KEY_DOLLAR_SCHEMA = "\$schema" + private const val KEY_DOLLAR_ID = "\$id" + private const val KEY_DOLLAR_REF = "\$ref" + private const val PROP_PROPERTIES = "properties" + private const val PROP_ADDITIONAL_PROPERTIES = "additionalProperties" + private const val PROP_TYPE = "type" + private const val PROP_TITLE = "title" + private const val PROP_ITEMS = "items" + + // JSON Schema combinators + private const val COMBINATOR_ONE_OF = "oneOf" + private const val COMBINATOR_ANY_OF = "anyOf" + private const val COMBINATOR_ALL_OF = "allOf" + + // Types & Drafts + private const val TYPE_STRING = "string" + private const val TYPE_OBJECT = "object" + private const val TYPE_ARRAY = "array" + private const val SCHEMA_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema" + private const val FILE_COMMON_TYPES = "common_types.json" + + // A2UI Component Graph Keys + private const val PROP_COMPONENT = "component" + private const val PROP_CHILD = "child" + private const val PROP_CHILDREN = "children" + private const val PROP_CONTENT_CHILD = "contentChild" + private const val PROP_ENTRY_POINT_CHILD = "entryPointChild" + private const val PROP_COMPONENT_ID = "componentId" + private const val PROP_EXPLICIT_LIST = "explicitList" + private const val PROP_TEMPLATE = "template" + private const val TITLE_COMPONENT_ID = "ComponentId" + private const val TITLE_CHILD_LIST = "ChildList" + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2aHandlerTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2aHandlerTest.kt new file mode 100644 index 000000000..349f0d98d --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2aHandlerTest.kt @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.a2a + +import com.google.adk.agents.RunConfig +import com.google.adk.events.Event +import com.google.adk.runner.Runner +import com.google.adk.sessions.BaseSessionService +import com.google.adk.sessions.GetSessionConfig +import com.google.adk.sessions.Session +import com.google.genai.types.Content +import com.google.genai.types.Part +import io.mockk.every +import io.mockk.mockk +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import java.util.Optional +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +@Suppress("DEPRECATION", "UNCHECKED_CAST") +class A2aHandlerTest { + + @Test + fun handleA2aPost_replacesSmartQuotesInA2uiJson() { + val mockRunner = mockk(relaxed = true) + val mockSessionService = mockk(relaxed = true) + val mockSession = mockk(relaxed = true) + + every { mockRunner.appName() } returns "test-app" + every { mockRunner.sessionService() } returns mockSessionService + every { + mockSessionService.getSession( + any(), + any(), + any(), + any>(), + ) + } returns Maybe.just(mockSession) + + val smartQuotesJson = + "[\n {\n “beginRendering”: {\n “surfaceId”: “sales-dashboard”\n }\n }\n]" + + val expectedDataObj = mapOf("beginRendering" to mapOf("surfaceId" to "sales-dashboard")) + + val partText = "Here is your chart:\n\n$smartQuotesJson\n\nEnjoy!" + val expectedNormalizedText = "Here is your chart:\n\nEnjoy!" + + val mockPart = mockk(relaxed = true) + every { mockPart.text() } returns Optional.of(partText) + every { mockPart.functionCall() } returns Optional.empty() + + val mockContent = mockk(relaxed = true) + every { mockContent.parts() } returns Optional.of(listOf(mockPart)) + + val mockEvent = mockk(relaxed = true) + every { mockEvent.id() } returns "test-event-id" + every { mockEvent.content() } returns Optional.of(mockContent) + + every { + mockRunner.runAsync(any(), any(), any()) + } returns Flowable.just(mockEvent) + + val handler = A2aHandler(mockRunner) + + val requestBody = + mapOf( + "jsonrpc" to "2.0", + "method" to "message/send", + "id" to 1, + "params" to + mapOf( + "message" to + mapOf( + "contextId" to "test-context", + "parts" to emptyList(), + "metadata" to + mapOf( + "a2uiClientCapabilities" to + mapOf( + "supportedCatalogIds" to + listOf( + "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + ) + ) + ), + ) + ), + ) + + val response = handler.handleA2aPost(requestBody) + + @Suppress("UNCHECKED_CAST") val result = response["result"] as Map + @Suppress("UNCHECKED_CAST") val parts = result["parts"] as List> + + assertEquals(3, parts.size) + assertEquals("Here is your chart:", parts[0]["text"]) + + val dataPart = parts[1] + assertEquals("data", dataPart["kind"]) + assertEquals("application/json+a2ui", (dataPart["metadata"] as Map)["mimeType"]) + assertEquals(expectedDataObj, dataPart["data"]) + + assertEquals("Enjoy!", parts[2]["text"]) + } + + @Test + fun handleA2aPost_passesRegularTextThrough() { + val mockRunner = mockk(relaxed = true) + val mockSessionService = mockk(relaxed = true) + val mockSession = mockk(relaxed = true) + + every { mockRunner.appName() } returns "test-app" + every { mockRunner.sessionService() } returns mockSessionService + every { + mockSessionService.getSession( + any(), + any(), + any(), + any>(), + ) + } returns Maybe.just(mockSession) + + val partText = "This is a normal conversational turn." + + val mockPart = mockk(relaxed = true) + every { mockPart.text() } returns Optional.of(partText) + every { mockPart.functionCall() } returns Optional.empty() + + val mockContent = mockk(relaxed = true) + every { mockContent.parts() } returns Optional.of(listOf(mockPart)) + + val mockEvent = mockk(relaxed = true) + every { mockEvent.id() } returns "test-event-id" + every { mockEvent.content() } returns Optional.of(mockContent) + + every { + mockRunner.runAsync(any(), any(), any()) + } returns Flowable.just(mockEvent) + + val handler = A2aHandler(mockRunner) + + val requestBody = + mapOf( + "jsonrpc" to "2.0", + "method" to "message/send", + "id" to 1, + "params" to + mapOf( + "message" to + mapOf( + "contextId" to "test-context", + "parts" to emptyList(), + "metadata" to + mapOf( + "a2uiClientCapabilities" to + mapOf( + "supportedCatalogIds" to + listOf( + "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + ) + ) + ), + ) + ), + ) + + val response = handler.handleA2aPost(requestBody) + + @Suppress("UNCHECKED_CAST") val result = response["result"] as Map + @Suppress("UNCHECKED_CAST") val parts = result["parts"] as List> + + assertEquals(1, parts.size) + assertEquals("text", parts[0]["kind"]) + assertEquals(partText, parts[0]["text"]) + } + + @Test + fun handleA2aPost_gracefullyHandlesInvalidA2uiJson() { + val mockRunner = mockk(relaxed = true) + val mockSessionService = mockk(relaxed = true) + val mockSession = mockk(relaxed = true) + + every { mockRunner.appName() } returns "test-app" + every { mockRunner.sessionService() } returns mockSessionService + every { + mockSessionService.getSession( + any(), + any(), + any(), + any>(), + ) + } returns Maybe.just(mockSession) + + val invalidJson = "[\n {\n “beginRendering”: { MISSING CLOSING BRACKETS..." + + val partText = "Here is an invalid chart:\n\n$invalidJson\n\nOops." + + val mockPart = mockk(relaxed = true) + every { mockPart.text() } returns Optional.of(partText) + every { mockPart.functionCall() } returns Optional.empty() + + val mockContent = mockk(relaxed = true) + every { mockContent.parts() } returns Optional.of(listOf(mockPart)) + + val mockEvent = mockk(relaxed = true) + every { mockEvent.id() } returns "test-event-id" + every { mockEvent.content() } returns Optional.of(mockContent) + + every { + mockRunner.runAsync(any(), any(), any()) + } returns Flowable.just(mockEvent) + + val handler = A2aHandler(mockRunner) + + val requestBody = + mapOf( + "jsonrpc" to "2.0", + "method" to "message/send", + "id" to 1, + "params" to + mapOf( + "message" to + mapOf( + "contextId" to "test-context", + "parts" to emptyList(), + "metadata" to + mapOf( + "a2uiClientCapabilities" to + mapOf( + "supportedCatalogIds" to + listOf( + "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + ) + ) + ), + ) + ), + ) + + val response = handler.handleA2aPost(requestBody) + + @Suppress("UNCHECKED_CAST") val result = response["result"] as Map + @Suppress("UNCHECKED_CAST") val parts = result["parts"] as List> + + assertEquals(1, parts.size) + assertEquals("text", parts[0]["kind"]) + assertEquals(partText, parts[0]["text"]) + } + + @Test + fun handleA2aPost_invokesSessionPreparerWithFullRequestBody() { + val mockRunner = mockk(relaxed = true) + val mockSessionService = mockk(relaxed = true) + val mockSession = mockk(relaxed = true) + + every { mockRunner.appName() } returns "test-app" + every { mockRunner.sessionService() } returns mockSessionService + every { mockSessionService.getSession(any(), any(), any(), any()) } returns + Maybe.just(mockSession) + + val handler = A2aHandler(mockRunner) + + val requestBody = + mapOf( + "jsonrpc" to "2.0", + "method" to "message/send", + "id" to 1, + "params" to mapOf("message" to mapOf("parts" to emptyList())), + ) + var capturedRequestBody: Map<*, *>? = null + + handler.handleA2aPost(requestBody) { _, rb -> capturedRequestBody = rb } + + assertEquals(requestBody, capturedRequestBody) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2uiA2aTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2uiA2aTest.kt new file mode 100644 index 000000000..e5170a043 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/a2a/A2uiA2aTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.a2a + +import io.a2a.spec.DataPart +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class A2uiA2aTest { + @Test + fun createA2uiPart_setsCorrectMimeType() { + val data = buildJsonObject { put("foo", "bar") } + val part = A2uiA2a.createA2uiPart(data) + assertTrue(part is DataPart) + assertEquals(A2uiA2a.A2UI_MIME_TYPE, (part as DataPart).metadata?.get(A2uiA2a.MIME_TYPE_KEY)) + } + + @Test + fun isA2uiPart_identifiesCorrectPart() { + val data = buildJsonObject { put("foo", "bar") } + val part = A2uiA2a.createA2uiPart(data) + assertTrue(A2uiA2a.isA2uiPart(part)) + } + + @Test + fun tryActivateA2uiExtension_requested_returnsTrue() { + val activated = mutableListOf() + val result = + A2uiA2a.tryActivateA2uiExtension(listOf(A2uiA2a.A2UI_EXTENSION_URI)) { activated.add(it) } + + assertTrue(result) + assertEquals(listOf(A2uiA2a.A2UI_EXTENSION_URI), activated) + } + + @Test + fun tryActivateA2uiExtension_notRequested_returnsFalse() { + val activated = mutableListOf() + val result = A2uiA2a.tryActivateA2uiExtension(listOf("other")) { activated.add(it) } + + kotlin.test.assertFalse(result) + assertTrue(activated.isEmpty()) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/ConvertersTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/ConvertersTest.kt new file mode 100644 index 000000000..19e8b39b7 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/ConvertersTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.adk.a2a_extension + +import com.google.a2ui.core.schema.A2uiCatalog +import com.google.adk.a2a.converters.EventConverter +import com.google.adk.agents.InvocationContext +import com.google.adk.events.Event +import com.google.adk.sessions.Session +import com.google.common.collect.ImmutableList +import com.google.genai.types.Content +import com.google.genai.types.FinishReason +import com.google.genai.types.Part +import io.a2a.spec.TaskState +import io.a2a.spec.TaskStatusUpdateEvent +import io.a2a.spec.TextPart +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import java.util.Optional +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class A2uiEventConverterTest { + + @Test + fun `emits FAILED TaskStatusUpdateEvent when event has errorCode`() { + val session = mockk() + every { session.state() } returns + java.util.concurrent.ConcurrentHashMap( + mapOf("system:a2ui_catalog" to mockk()) + ) + + val context = mockk() + every { context.session() } returns session + + val mockEvent = mockk() + val finishReasonArg = mockk() + every { mockEvent.errorCode() } returns Optional.of(finishReasonArg) + every { mockEvent.errorMessage() } returns Optional.of("Server crash") + every { mockEvent.content() } returns Optional.empty() + every { mockEvent.author() } returns "test_author" + + mockkStatic(EventConverter::class) + every { EventConverter.taskId(mockEvent) } returns "task-1" + every { EventConverter.contextId(mockEvent) } returns "context-1" + + val converter = A2uiEventConverter() + val results = converter.convert(mockEvent, context) + + assertEquals(1, results.size) + val result = results[0] + assertIs(result) + + assertEquals("task-1", result.taskId()) + assertEquals("context-1", result.contextId()) + assertEquals(TaskState.TASK_STATE_FAILED, result.status().state()) + + val msg = result.status().message()!! + assertEquals(1, msg.parts()!!.size) + val part = msg.parts()!![0] as TextPart + assertEquals("Server crash", part.text()) + } + + @Test + fun `emits WORKING TaskStatusUpdateEvent when event has standard content`() { + val catalog = mockk() + val session = mockk() + every { session.state() } returns + java.util.concurrent.ConcurrentHashMap(mapOf("system:a2ui_catalog" to catalog)) + + val context = mockk() + every { context.session() } returns session + + // Using Mockk for GenAI Content and Part + val mockGenaiPart = mockk() + every { mockGenaiPart.functionResponse() } returns Optional.empty() + every { mockGenaiPart.functionCall() } returns Optional.empty() + every { mockGenaiPart.text() } returns Optional.of("Regular text response") + + val mockContent = mockk() + every { mockContent.parts() } returns Optional.of(listOf(mockGenaiPart)) + + val mockEvent = mockk() + every { mockEvent.errorCode() } returns Optional.empty() + every { mockEvent.content() } returns Optional.of(mockContent) + every { mockEvent.author() } returns "test_author" + + mockkStatic(EventConverter::class) + // mock behavior of fallback standard conversion + every { EventConverter.contentToParts(any(), false) } returns + ImmutableList.of(TextPart("Regular text response")) + + every { EventConverter.taskId(mockEvent) } returns "task-123" + every { EventConverter.contextId(mockEvent) } returns "context-123" + + val converter = A2uiEventConverter() + val results = converter.convert(mockEvent, context) + + assertEquals(1, results.size) + val result = results[0] + assertIs(result) + + assertEquals("task-123", result.taskId()) + assertEquals("context-123", result.contextId()) + assertEquals(TaskState.TASK_STATE_WORKING, result.status().state()) + + val msg = result.status().message()!! + assertTrue(msg.parts()!!.isNotEmpty()) + val part = msg.parts()!![0] as TextPart + assertEquals("Regular text response", part.text()) + } + + @Test + fun `returns empty list when catalog is missing from session state`() { + val session = mockk() + every { session.state() } returns + java.util.concurrent.ConcurrentHashMap() // No catalog! + + val context = mockk() + every { context.session() } returns session + + val event = mockk() + + val converter = A2uiEventConverter() + val results = converter.convert(event, context) + + assertTrue(results.isEmpty()) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolsetTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolsetTest.kt new file mode 100644 index 000000000..3b1e86531 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/adk/a2a_extension/SendA2uiToClientToolsetTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.adk.a2a_extension + +import com.google.a2ui.core.schema.A2uiCatalog +import com.google.a2ui.core.schema.A2uiVersion +import com.google.adk.agents.ReadonlyContext +import com.google.adk.tools.ToolContext +import io.mockk.mockk +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SendA2uiToClientToolsetTest { + + private val catalogSchema = + Json.parseToJsonElement( + """ + { + "catalogId": "dummy", + "components": { + "TestComp": {"type": "object"} + } + } + """ + ) + .jsonObject as JsonObject + + // Minimal permissive serverToClient schema for testing + private val serverToClientSchema = + Json.parseToJsonElement( + """ + { + "type": "object", + "properties": { + "beginRendering": {"type": "object", "properties": {"root": {"type": "object"}}} + } + } + """ + ) + .jsonObject as JsonObject + + private val dummyCatalog = + A2uiCatalog( + version = A2uiVersion.VERSION_0_9, + name = "dummy", + serverToClientSchema = serverToClientSchema, + commonTypesSchema = JsonObject(emptyMap()), + catalogSchema = catalogSchema, + ) + + private val mockContext = mockk(relaxed = true) + private val mockToolContext = mockk(relaxed = true) + + // Removed testsNameAndDescription because BaseTool properties are protected/private in ADK + + @Test + fun execute_validPayload_emitsPayload() { + val toolset = SendA2uiToClientToolset.create(true, dummyCatalog, "") + val tool = toolset.getTools(mockContext).blockingFirst() + + val a2uiJsonStr = """{"beginRendering": {"root": {"component": "TestComp", "id": "1"}}}""" + + val args = mapOf("a2ui_json" to a2uiJsonStr) + val result = tool.runAsync(args, mockToolContext).blockingGet() + + assertNotNull(result[SendA2uiToClientToolset.VALIDATED_A2UI_JSON_KEY]) + val validatedPayload = + result[SendA2uiToClientToolset.VALIDATED_A2UI_JSON_KEY] + as kotlinx.serialization.json.JsonElement + + assertTrue(validatedPayload.toString().contains("beginRendering")) + assertTrue(validatedPayload.toString().contains("TestComp")) + } + + @Test + fun execute_invalidPayload_rejectsPayload() { + val toolset = SendA2uiToClientToolset.create(true, dummyCatalog, "") + val tool = toolset.getTools(mockContext).blockingFirst() + + // Missing a2ui_json argument entirely + val args = mapOf("wrong_arg" to "b") + val result = tool.runAsync(args, mockToolContext).blockingGet() + + assertNotNull(result[SendA2uiToClientToolset.TOOL_ERROR_KEY]) + val errorMsg = result[SendA2uiToClientToolset.TOOL_ERROR_KEY] as String + assertTrue(errorMsg.contains("missing required arg a2ui_json")) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/ParserTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/ParserTest.kt new file mode 100644 index 000000000..cc12d3a78 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/ParserTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.parser + +import com.google.a2ui.core.schema.A2uiConstants +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ParserTest { + + @Test + fun mixedText_extractsA2uiBlocks() { + val mixedText = + """ + Here is some response text. + ${A2uiConstants.A2UI_OPEN_TAG} + {"a": 1} + ${A2uiConstants.A2UI_CLOSE_TAG} + And some trailing output. + """ + .trimIndent() + + val blocks = parseResponseToParts(mixedText) + assertEquals(2, blocks.size) + assertEquals("Here is some response text.", blocks[0].text) + assertEquals("{\"a\":1}", blocks[0].a2uiJson!![0].toString()) + assertEquals("And some trailing output.", blocks[1].text) + assertEquals(null, blocks[1].a2uiJson) + } + + @Test + fun noTagsPresent_throwsException() { + val text = "Just some random { \"key\": null } JSON without tags" + var threw = false + try { + parseResponseToParts(text) + } catch (e: IllegalArgumentException) { + threw = true + } + assertTrue(threw) + } + + @Test + fun textWithTags_returnsTrueForHasA2uiParts() { + assertTrue( + hasA2uiParts("foo\n${A2uiConstants.A2UI_OPEN_TAG}\nbar\n${A2uiConstants.A2UI_CLOSE_TAG}") + ) + assertFalse(hasA2uiParts("No parts here!")) + } + + @Test + fun multipleBlocks_extractsRightBlocks() { + val mixedText = + """ + Prefix + ${A2uiConstants.A2UI_OPEN_TAG}{"first": 1}${A2uiConstants.A2UI_CLOSE_TAG} + Middle + ${A2uiConstants.A2UI_OPEN_TAG} {"second": 2} ${A2uiConstants.A2UI_CLOSE_TAG} + Suffix + """ + .trimIndent() + + val blocks = parseResponseToParts(mixedText) + assertEquals(3, blocks.size) + assertEquals("Prefix", blocks[0].text) + assertEquals("{\"first\":1}", blocks[0].a2uiJson!![0].toString()) + assertEquals("Middle", blocks[1].text) + assertEquals("{\"second\":2}", blocks[1].a2uiJson!![0].toString()) + assertEquals("Suffix", blocks[2].text) + assertEquals(null, blocks[2].a2uiJson) + } + + @Test + fun invalidJson_throwsException() { + val mixedText = + """ + ${A2uiConstants.A2UI_OPEN_TAG} + { invalid_json: + ${A2uiConstants.A2UI_CLOSE_TAG} + """ + .trimIndent() + + var threw = false + try { + parseResponseToParts(mixedText) + } catch (e: Exception) { + threw = true + } + assertTrue(threw) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/PayloadFixerTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/PayloadFixerTest.kt new file mode 100644 index 000000000..33d9a14b3 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/PayloadFixerTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.parser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PayloadFixerTest { + + @Test + fun arrayWithTrailingCommas_commasRemoved() { + val invalidJson = + """ + [ + {"foo": "bar"}, + {"foo": "baz"}, + ] + """ + .trimIndent() + val result = PayloadFixer.parseAndFix(invalidJson) + assertEquals(2, result.size) + } + + @Test + fun objectWithTrailingCommas_commasRemoved() { + val invalidJson = + """ + { + "foo": "bar", + "foo2": "baz", + } + """ + .trimIndent() + val result = PayloadFixer.parseAndFix(invalidJson) + assertEquals(1, result.size) + } + + @Test + fun edgeCasesWithTrailingCommas_commasRemoved() { + assertEquals("""{"a": 1}""", PayloadFixer.removeTrailingCommas("""{"a": 1,}""")) + assertEquals("""[1, 2, 3]""", PayloadFixer.removeTrailingCommas("""[1, 2, 3,]""")) + assertEquals("""{"a": [1, 2]}""", PayloadFixer.removeTrailingCommas("""{"a": [1, 2,]}""")) + } + + @Test + fun commasInStrings_notRemoved() { + val jsonWithCommasInStrings = """{"text": "Hello, world", "array": ["a,b", "c"]}""" + assertEquals( + jsonWithCommasInStrings, + PayloadFixer.removeTrailingCommas(jsonWithCommasInStrings), + ) + + val trickyJson = """{"text": "Ends with comma,]"}""" + assertEquals(trickyJson, PayloadFixer.removeTrailingCommas(trickyJson)) + } + + @Test + fun validJson_remainsUntouched() { + val validJson = """[{"foo": "bar"}]""" + val result = PayloadFixer.parseAndFix(validJson) + assertEquals(1, result.size) + } + + @Test + fun unrecoverableJson_throwsException() { + val badJson = "not_json_at_all" + assertFailsWith { PayloadFixer.parseAndFix(badJson) } + } + + @Test + fun normalizeSmartQuotes_replacesQuotesCorrectly() { + val input = "“smart” ‘quotes’" + val expected = "\"smart\" 'quotes'" + assertEquals(expected, PayloadFixer.normalizeSmartQuotes(input)) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/SanitizationTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/SanitizationTest.kt new file mode 100644 index 000000000..ebaeadfe5 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/parser/SanitizationTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.parser + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SanitizationTest { + @Test + fun sanitizeJsonString_stripsMarkdown() { + val input = + """ + ```json + {"a": 1} + ``` + """ + .trimIndent() + assertEquals("{\"a\": 1}", sanitizeJsonString(input)) + } + + @Test + fun sanitizeJsonString_stripsRawMarkdown() { + val input = + """ + ``` + {"a": 1} + ``` + """ + .trimIndent() + assertEquals("{\"a\": 1}", sanitizeJsonString(input)) + } + + @Test + fun sanitizeJsonString_noMarkdown() { + val input = """{"a": 1}""" + assertEquals("{\"a\": 1}", sanitizeJsonString(input)) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/A2uiSchemaManagerTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/A2uiSchemaManagerTest.kt new file mode 100644 index 000000000..2ae027c7c --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/A2uiSchemaManagerTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +class A2uiSchemaManagerTest { + + private val dummyProvider = + object : A2uiCatalogProvider { + override fun load(): JsonObject { + return Json.parseToJsonElement( + """ + { + "catalogId": "test_cat", + "components": { + "TestComp": { + "type": "object" + } + } + } + """ + ) + .jsonObject + } + } + + private val catalogConfig = CatalogConfig("Test", dummyProvider) + + @Test + fun instantiation_localCatalogSet_succeeds() { + val manager = A2uiSchemaManager(A2uiVersion.VERSION_0_9, listOf(catalogConfig)) + assertEquals(listOf("test_cat"), manager.supportedCatalogIds) + } + + @Test + fun getSystemPrompt_promptRequested_returnsInstructions() { + val manager = A2uiSchemaManager(A2uiVersion.VERSION_0_9, listOf(catalogConfig)) + val prompt = + manager.generateSystemPrompt( + roleDescription = "You are a helpful UI agent.", + workflowDescription = "Render simple UI.", + uiDescription = "Use TestComp heavily.", + clientUiCapabilities = null, + allowedComponents = emptyList(), + includeSchema = true, + includeExamples = false, + validateExamples = false, + ) + + assertTrue(prompt.contains("You are a helpful UI agent.")) + assertTrue(prompt.contains("## Workflow Description:\n")) + assertTrue(prompt.contains("Render simple UI.")) + assertTrue(prompt.contains("## UI Description:\nUse TestComp heavily.")) + assertTrue(prompt.contains(A2uiConstants.A2UI_SCHEMA_BLOCK_START)) + assertTrue(prompt.contains("\"test_cat\"")) + } + + @Test + fun getSelectedCatalog_inlineCatalogsCapabilityProvided_returnsInlineCatalog() { + // manager must have acceptsInlineCatalogs = true + val manager = + A2uiSchemaManager( + version = A2uiVersion.VERSION_0_9, + catalogs = listOf(catalogConfig), + acceptsInlineCatalogs = true, + ) + + val inlineCaps = + Json.parseToJsonElement( + """ + { + "inlineCatalogs": [ + { + "components": { + "client_comp": {"type": "object"} + } + } + ] + } + """ + ) + .jsonObject + + val catalog = manager.getSelectedCatalog(inlineCaps, emptyList()) + assertEquals("test_cat", catalog.catalogId) // Base catalog ID is preserved + assertEquals("inline", catalog.name) + assertTrue( + "TestComp" in catalog.catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY]!!.jsonObject + ) + assertTrue( + "client_comp" in catalog.catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY]!!.jsonObject + ) + } + + @Test + fun getSelectedCatalog_inlineCatalogsDisabled_returnsFallback() { + val manager = + A2uiSchemaManager( + version = A2uiVersion.VERSION_0_9, + catalogs = listOf(catalogConfig), + acceptsInlineCatalogs = false, // Disabled + ) + + val inlineCaps = + Json.parseToJsonElement( + """ + { "inlineCatalogs": [{"catalogId": "inline", "components": {}}] } + """ + ) + .jsonObject + + assertFailsWith("Agent does not accept inline catalogs") { + manager.getSelectedCatalog(inlineCaps, emptyList()) + } + } + + @Test + fun getSelectedCatalog_unsupportedCatalogRequested_throwsException() { + val manager = A2uiSchemaManager(A2uiVersion.VERSION_0_9, listOf(catalogConfig)) + + val caps = + Json.parseToJsonElement( + """ + { "supportedCatalogIds": ["unknown_catalog_id"] } + """ + ) + .jsonObject + + assertFailsWith("No client-supported catalog found") { + manager.getSelectedCatalog(caps, emptyList()) + } + } + + @Test + fun getSelectedCatalog_inlineAndSupportedCapabilitiesProvided_mergesOntoSpecifiedBase() { + val manager = + A2uiSchemaManager( + version = A2uiVersion.VERSION_0_9, + catalogs = listOf(catalogConfig), + acceptsInlineCatalogs = true, + ) + + val caps = + Json.parseToJsonElement( + """ + { + "supportedCatalogIds": ["test_cat"], + "inlineCatalogs": [ + { + "components": { + "client_comp": {"type": "object"} + } + } + ] + } + """ + ) + .jsonObject + + val catalog = manager.getSelectedCatalog(caps, emptyList()) + assertEquals("test_cat", catalog.catalogId) + assertTrue( + "TestComp" in catalog.catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY]!!.jsonObject + ) + assertTrue( + "client_comp" in catalog.catalogSchema[A2uiConstants.CATALOG_COMPONENTS_KEY]!!.jsonObject + ) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt new file mode 100644 index 000000000..d18dc13d5 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/CatalogTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +class CatalogTest { + + private fun createDummyCatalog(): A2uiCatalog { + val serverToClientSchema = Json.parseToJsonElement("""{"s2c": true}""") as JsonObject + val common = Json.parseToJsonElement("""{"common": true}""") as JsonObject + val catalogSchema = + Json.parseToJsonElement( + """ + { + "catalogId": "dummy_catalog", + "components": { + "AllowedComp": {"type": "object"}, + "PrunedComp": {"type": "object"} + }, + "${"$"}defs": { + "anyComponent": { + "oneOf": [ + {"${"$"}ref": "#/components/AllowedComp"}, + {"${"$"}ref": "#/components/PrunedComp"}, + {"${"$"}ref": "https://a2ui.org/other"} + ] + } + } + } + """ + .trimIndent() + ) as JsonObject + + return A2uiCatalog( + version = A2uiVersion.VERSION_0_9, + name = "dummy", + serverToClientSchema = serverToClientSchema, + commonTypesSchema = common, + catalogSchema = catalogSchema, + ) + } + + @Test + fun catalog_rendersAsLlmInstructions() { + val catalog = createDummyCatalog() + val instructions = catalog.renderAsLlmInstructions() + + assertTrue(instructions.startsWith(A2uiConstants.A2UI_SCHEMA_BLOCK_START)) + assertTrue(instructions.endsWith(A2uiConstants.A2UI_SCHEMA_BLOCK_END)) + assertTrue(instructions.contains("### Server To Client Schema:\n{\"s2c\":true}")) + assertTrue(instructions.contains("### Common Types Schema:\n{\"common\":true}")) + assertTrue(instructions.contains("### Catalog Schema:\n{")) + } + + @Test + fun allowlistProvided_prunesComponentsCorrectly() { + val catalog = createDummyCatalog() + val allowed = listOf("AllowedComp") + + val prunedCatalog = catalog.withPrunedComponents(allowed) + + val prunedComponents = prunedCatalog.catalogSchema["components"] as JsonObject + assertTrue("AllowedComp" in prunedComponents) + assertTrue("PrunedComp" !in prunedComponents) + + // Verify oneOf filtering in anyComponent + val anyComponentStr = prunedCatalog.catalogSchema.toString() + assertTrue("#/components/AllowedComp" in anyComponentStr) + assertTrue("#/components/PrunedComp" !in anyComponentStr) + } + + @Test + fun validExamplePath_loadsAndValidatesExamples() { + val dir = + File(System.getProperty("java.io.tmpdir"), "a2ui_examples_${System.currentTimeMillis()}") + dir.mkdirs() + try { + val ex1 = File(dir, "ex1.json").apply { writeText("""{"example": 1}""") } + val ex2 = File(dir, "ex2.txt").apply { writeText("ignore me") } + val ex3 = File(dir, "ex3.json").apply { writeText("invalid_json_here") } + + val catalog = createDummyCatalog() + // We pass false to validate because our dummy validator will fail everything + val examplesContent = catalog.loadExamples(dir.absolutePath, validate = false) + + assertTrue(examplesContent.contains("---BEGIN ex1---")) + assertTrue(examplesContent.contains("{\"example\": 1}")) + // invalid json loads anyway if validation is false + assertTrue(examplesContent.contains("---BEGIN ex3---")) + // txt file ignored + assertTrue(!examplesContent.contains("ignore me")) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/SchemaModifiersTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/SchemaModifiersTest.kt new file mode 100644 index 000000000..f4f3645a6 --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/SchemaModifiersTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class SchemaModifiersTest { + @Test + fun removeStrictValidation_removesAdditionalPropertiesFalse() { + val schema = + Json.parseToJsonElement( + """ + { + "type": "object", + "properties": { + "a": { "type": "string", "additionalProperties": false } + }, + "additionalProperties": false + } + """ + ) + .jsonObject + + val modified = SchemaModifiers.removeStrictValidation(schema) + + assertNull(modified["additionalProperties"]) + val props = modified["properties"]!!.jsonObject + val aSchema = props["a"]!!.jsonObject + assertNull(aSchema["additionalProperties"]) + } + + @Test + fun removeStrictValidation_keepsAdditionalPropertiesTrue() { + val schema = + Json.parseToJsonElement( + """ + { + "type": "object", + "additionalProperties": true + } + """ + ) + .jsonObject + + val modified = SchemaModifiers.removeStrictValidation(schema) + + assertTrue(modified["additionalProperties"]!!.jsonPrimitive.booleanOrNull!!) + } +} diff --git a/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt new file mode 100644 index 000000000..bac53684c --- /dev/null +++ b/agent_sdks/kotlin/src/test/kotlin/com/google/a2ui/core/schema/ValidatorTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.a2ui.core.schema + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +class ValidatorTest { + + private fun createDummyCatalog(version: A2uiVersion): A2uiCatalog { + val serverToClientSchema = + Json.parseToJsonElement( + if (version == A2uiVersion.VERSION_0_8) { + """{"type": "object", "properties": {"surfaceUpdate": {"type": "object", "properties": {"root": {"type": "object"}}}}}""" + } else { + """{"type": "object", "properties": { + "beginRendering": {"type": "object", "properties": {"root": {"type": "object"}}}, + "updateDataModel": {"type": "object", "additionalProperties": true} + }}""" + } + ) as JsonObject + + val catalogSchema = + Json.parseToJsonElement( + """ + { + "catalogId": "dummy_catalog", + "components": { + "TestComp": {"type": "object", "properties": {"child": {"type": "object"}, "children": {"type": "array"}}} + }, + "${"$"}defs": { + } + } + """ + .trimIndent() + ) as JsonObject + + return A2uiCatalog( + version = version, + name = "dummy", + serverToClientSchema = serverToClientSchema as JsonObject, + commonTypesSchema = Json.parseToJsonElement("{}") as JsonObject, + catalogSchema = catalogSchema, + ) + } + + @Test + fun validate_standardPayload09_validationSucceeds() { + val catalog = createDummyCatalog(A2uiVersion.VERSION_0_9) + val validator = A2uiValidator(catalog) + + val payload = + Json.parseToJsonElement( + """ + { + "beginRendering": { + "root": { + "component": { "componentId": "TestComp" } + } + } + } + """ + .trimIndent() + ) + + // Should not throw + validator.validate(payload) + } + + @Test + fun validate_standardPayload08_validationSucceeds() { + val catalog = createDummyCatalog(A2uiVersion.VERSION_0_8) + val validator = A2uiValidator(catalog) + + val payload = + Json.parseToJsonElement( + """ + { + "surfaceUpdate": { + "root": { + "component": { "componentId": "TestComp" } + } + } + } + """ + .trimIndent() + ) + + // Should not throw + validator.validate(payload) + } + + @Test + fun validate_missingRoot_throwsException() { + val catalog = createDummyCatalog(A2uiVersion.VERSION_0_9) + val validator = A2uiValidator(catalog) + + val payload = + Json.parseToJsonElement( + """ + { + "beginRendering": { + "notRoot": {} + } + } + """ + .trimIndent() + ) + + // Validation against S2C schema will fail because root is missing but may just be ignored if + // not required in my dummy schema. + // Let's actually test A2UI topology logic by putting an invalid component ref for + // `updateComponents`. + + val payloadUpdate = + Json.parseToJsonElement( + """ + { + "updateComponents": { + "components": [ + { + "component": "UnknownComp", + "id": "123" + } + ] + } + } + """ + .trimIndent() + ) + + assertFailsWith { validator.validate(payloadUpdate) } + } + + @Test + fun validate_circularReferences_throwsException() { + val catalog = createDummyCatalog(A2uiVersion.VERSION_0_9) + val validator = A2uiValidator(catalog) + + val payload = + Json.parseToJsonElement( + """ + { + "updateComponents": { + "components": [ + { + "component": "TestComp", + "id": "root", + "children": [ "B" ] + }, + { + "component": "TestComp", + "id": "B", + "children": [ "root" ] + } + ] + } + } + """ + .trimIndent() + ) + + try { + validator.validate(payload) + throw AssertionError("Expected Circular reference exception, got nothing.") + } catch (e: IllegalArgumentException) { + kotlin.test.assertTrue( + e.message!!.contains("Circular reference detected") || + e.message!!.contains("Max function call"), + "Unexpected exception: ${e.message}", + ) + } + } + + @Test + fun validate_globalRecursionLimits_throwsException() { + val catalog = createDummyCatalog(A2uiVersion.VERSION_0_9) + val validator = A2uiValidator(catalog) + + // build deeply nested payload > 50 + var jsonStr = """{"level": 0}""" + for (i in 1..60) { + jsonStr = """{"next": $jsonStr}""" + } + jsonStr = """{"updateDataModel": {"value": $jsonStr}}""" + + val payload = Json.parseToJsonElement(jsonStr) + + // Because the JSON validator might fail before recursion checks, we catch the exception. + // As long as it throws something, we are okay, but ideally we make a permissive schema. + try { + validator.validate(payload) + throw AssertionError("Expected limit exception, got nothing.") + } catch (e: Exception) { + kotlin.test.assertTrue( + e.message!!.contains("Global recursion limit exceeded") || + e.message!!.contains("Validation failed") || + e.message!!.contains("not a valid"), + "Unexpected exception: ${e.message}", + ) + } + } +}