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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions a2a/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@

plugins {
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
id("maven-publish")
}

kotlin {
// AGP 9 KMP Android library target (replaces com.android.library + androidTarget).
android {
namespace = "com.google.adk.a2a"
compileSdk = rootProject.extra["androidCompileSdk"] as Int
// A2A requires API 35: the SDK's ClientBuilder.build() calls ServiceLoader.stream() (API 35+).
// Core stays at the shared androidMinSdk; only A2A gates higher.
minSdk = 35
}
jvm()

sourceSets {
Expand All @@ -38,14 +47,22 @@ kotlin {
val commonJvmAndroidMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.jackson.databind)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.kotlinx.serialization)
implementation(libs.a2a.sdk.client)
implementation(libs.a2a.sdk.common)
implementation(libs.a2a.sdk.spec)
}
}
// jvmMain: deprecated v0.3 (`io.a2a.*`) path, JVM-only.
// jvmMain hosts the deprecated v0.3 path (JVM-only); androidMain stays v1.0-only.
val jvmMain by getting {
dependsOn(commonJvmAndroidMain)
dependencies {
// JVM v1.0 factory uses the SDK's proto-based JSON-RPC transport; kept off the Android
// path.
implementation(libs.a2a.sdk.transport.jsonrpc)
// Jackson is JVM-only (deprecated v0.3 converters); kept off the Android artifact.
implementation(libs.jackson.databind)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.jackson.module.kotlin)
implementation(libs.a2a.legacy.sdk.client)
implementation(libs.a2a.legacy.sdk.common)
Expand All @@ -58,19 +75,26 @@ kotlin {
implementation(libs.google.truth)
implementation(libs.mockito.kotlin)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.okhttp.mockwebserver)
implementation(libs.a2a.legacy.sdk.client)
implementation(libs.a2a.legacy.sdk.spec)
}
}
val androidMain by getting {
dependsOn(commonJvmAndroidMain)
dependencies { implementation(libs.a2a.sdk.http.client.android) }
}
}
}

// Coordinates the Kotlin Multiplatform plugin uses for the publications it
// auto-creates:
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
// POM metadata, Dokka javadoc, and GPG signing are configured in the root
// build.gradle.kts.
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
// - `androidRelease` -> google-adk-kotlin-a2a-android (KMP target)
// Per-target suffixes (`-jvm`, `-android`) are appended by the KMP plugin
// automatically. POM metadata, Dokka javadoc, and GPG signing are configured in
// the root build.gradle.kts.
publishing {
publications.withType<MavenPublication>().configureEach {
if (name == "kotlinMultiplatform") {
Expand Down
20 changes: 20 additions & 0 deletions a2a/src/androidInstrumentedTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!-- Android manifest for the A2A instrumented test app. -->

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.adk.kt.a2a.instrumentation">

<uses-sdk android:minSdkVersion="26" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Cleartext is needed only because the test talks to a local http MockWebServer; a real A2A
server uses https, so this is a test-only allowance (real Android blocks cleartext by default,
which Robolectric does not enforce). -->
<application android:usesCleartextTraffic="true">
<uses-library android:name="android.test.runner" />
</application>

<instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.google.adk.kt.a2a.instrumentation" />

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* 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
*
* http://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.adk.kt.a2a.agent

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.adk.kt.a2a.android.AndroidA2AAgent
import com.google.adk.kt.agents.InvocationContext
import com.google.adk.kt.events.Event
import com.google.adk.kt.sessions.Session
import com.google.adk.kt.sessions.SessionKey
import com.google.adk.kt.testing.DummyAgent
import com.google.adk.kt.testing.userMessage
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageResponse
import org.a2aproject.sdk.spec.AgentCapabilities
import org.a2aproject.sdk.spec.AgentCard
import org.a2aproject.sdk.spec.AgentInterface
import org.a2aproject.sdk.spec.Message
import org.a2aproject.sdk.spec.Task
import org.a2aproject.sdk.spec.TaskState
import org.a2aproject.sdk.spec.TaskStatus
import org.a2aproject.sdk.spec.TextPart
import org.a2aproject.sdk.spec.TransportProtocol
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* On-device (ART) counterpart of `A2AAgentAndroidTest`: builds an Android A2A agent via
* [AndroidA2AAgent] and runs a round-trip against an in-process [MockWebServer], exercising the
* proto-free Android transport.
*
* The MockWebServer returns a real JSON-RPC response built with the SDK's own
* [JsonUtil] + [SendMessageResponse], and the transport parses it back with [JsonUtil], so this
* exercises the actual proto-free A2A serialization on a real Android runtime in both directions.
*/
@RunWith(AndroidJUnit4::class)
class A2AAgentInstrumentationTest {

private lateinit var server: MockWebServer

@Before
fun setUp() {
server = MockWebServer()
server.start()
}

@After
fun tearDown() {
server.shutdown()
}

@Test
fun runAsync_androidHttpClient_realRoundTrip_emitsAgentEventAndSendsRequest() = runTest {
val agentReply = "Hello from the Android A2A agent!"

// Build a real JSON-RPC `message/send` response with the SDK's own proto-free serialization:
// a completed Task whose status message carries the agent text. Using a completed Task (rather
// than a bare Message) lets the agent's non-streaming flow recognise the turn as terminal.
val agentMessage =
Message.builder()
.messageId("agent-message-1")
.role(Message.Role.ROLE_AGENT)
.parts(listOf(TextPart(agentReply)))
.build()
val responseTask =
Task.builder()
.id("android-task-1")
.contextId("android-context-1")
.status(TaskStatus(TaskState.TASK_STATE_COMPLETED, agentMessage, null))
.build()
val responseBody = JsonUtil.toJson(SendMessageResponse("2.0", "1", responseTask, null))
server.enqueue(MockResponse().setBody(responseBody))
val serverUrl = server.url("/a2a").toString()

val agentCard =
AgentCard.builder()
.name("remote-agent")
.description("Remote Agent")
.url(serverUrl)
.version("1.0.0")
.defaultInputModes(listOf("text"))
.defaultOutputModes(listOf("text"))
.skills(listOf())
.supportedInterfaces(
listOf(AgentInterface(TransportProtocol.JSONRPC.asString(), serverUrl))
)
.capabilities(AgentCapabilities.builder().streaming(false).build())
.build()

val agent = AndroidA2AAgent(name = "remote-agent", agentCard = agentCard)

val session =
Session(
key = SessionKey(appName = "demo", userId = "user", id = "session-1"),
events =
mutableListOf(
Event(invocationId = "invocation-0", author = "user", content = userMessage("hello"))
),
)
val context = InvocationContext(agent = DummyAgent(), session = session, runConfig = null)

val events = agent.runAsync(context).toList()

// The agent emitted an ADK Event carrying the agent text from the real round-trip.
val emittedTexts = events.mapNotNull { it.content?.parts?.firstOrNull()?.text }
assertThat(emittedTexts).contains(agentReply)

// The user message actually traversed AndroidA2AHttpClient and reached the server.
val recorded = server.takeRecordedRequestOrFail()
assertThat(recorded.path).isEqualTo("/a2a")
assertThat(recorded.method).isEqualTo("POST")
assertThat(recorded.body.readUtf8()).contains("hello")
}

@Test
fun runAsync_autoFetchCard_realRoundTrip_fetchesCardThenSendsMessage() = runTest {
val agentReply = "Hello from the auto-fetched Android A2A agent!"
val serverUrl = server.url("/a2a").toString()

val agentCard =
AgentCard.builder()
.name("remote-agent")
.description("Remote Agent")
.url(serverUrl)
.version("1.0.0")
.defaultInputModes(listOf("text"))
.defaultOutputModes(listOf("text"))
.skills(listOf())
.supportedInterfaces(
listOf(AgentInterface(TransportProtocol.JSONRPC.asString(), serverUrl))
)
.capabilities(AgentCapabilities.builder().streaming(false).build())
.build()
// 1) Served for the auto-fetch GET of `/.well-known/agent-card.json`.
server.enqueue(MockResponse().setBody(JsonUtil.toJson(agentCard)))

val agentMessage =
Message.builder()
.messageId("agent-message-1")
.role(Message.Role.ROLE_AGENT)
.parts(listOf(TextPart(agentReply)))
.build()
val responseTask =
Task.builder()
.id("android-task-1")
.contextId("android-context-1")
.status(TaskStatus(TaskState.TASK_STATE_COMPLETED, agentMessage, null))
.build()
// 2) Served for the `message/send` POST.
server.enqueue(
MockResponse().setBody(JsonUtil.toJson(SendMessageResponse("2.0", "1", responseTask, null)))
)

// No card supplied: the agent auto-fetches it from the well-known endpoint on-device.
val agent = AndroidA2AAgent(name = "remote-agent", agentCardUrl = server.url("/").toString())

val session =
Session(
key = SessionKey(appName = "demo", userId = "user", id = "session-1"),
events =
mutableListOf(
Event(invocationId = "invocation-0", author = "user", content = userMessage("hello"))
),
)
val context = InvocationContext(agent = DummyAgent(), session = session, runConfig = null)

val events = agent.runAsync(context).toList()

val emittedTexts = events.mapNotNull { it.content?.parts?.firstOrNull()?.text }
assertThat(emittedTexts).contains(agentReply)

// The card was fetched from the well-known endpoint first, then the message was sent.
val cardRequest = server.takeRecordedRequestOrFail()
assertThat(cardRequest.path).isEqualTo("/.well-known/agent-card.json")
assertThat(cardRequest.method).isEqualTo("GET")
val sendRequest = server.takeRecordedRequestOrFail()
assertThat(sendRequest.path).isEqualTo("/a2a")
assertThat(sendRequest.method).isEqualTo("POST")
assertThat(sendRequest.body.readUtf8()).contains("hello")
}
}

private fun MockWebServer.takeRecordedRequestOrFail(): RecordedRequest =
try {
takeRequest()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw AssertionError("Interrupted while waiting for the recorded request", e)
}
Loading
Loading