Skip to content

Commit 16ea7ba

Browse files
kvmiloscopybara-github
authored andcommitted
test(a2a): add an Android instrumentation test for the A2A client round-trip
PiperOrigin-RevId: 940476976
1 parent d65becd commit 16ea7ba

25 files changed

Lines changed: 4015 additions & 93 deletions

File tree

a2a/build.gradle.kts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,17 @@
1616

1717
plugins {
1818
kotlin("multiplatform")
19+
id("com.android.kotlin.multiplatform.library")
1920
id("maven-publish")
2021
}
2122

2223
kotlin {
24+
// AGP 9 KMP Android library target (replaces com.android.library + androidTarget).
25+
android {
26+
namespace = "com.google.adk.a2a"
27+
compileSdk = rootProject.extra["androidCompileSdk"] as Int
28+
minSdk = rootProject.extra["androidMinSdk"] as Int
29+
}
2330
jvm()
2431

2532
sourceSets {
@@ -38,14 +45,21 @@ kotlin {
3845
val commonJvmAndroidMain by creating {
3946
dependsOn(commonMain)
4047
dependencies {
48+
implementation(libs.kotlinx.serialization)
4149
implementation(libs.jackson.databind)
4250
implementation(libs.jackson.datatype.jsr310)
51+
implementation(libs.a2a.sdk.client)
52+
implementation(libs.a2a.sdk.common)
53+
implementation(libs.a2a.sdk.spec)
54+
implementation(libs.a2a.sdk.transport.rest)
4355
}
4456
}
45-
// jvmMain: deprecated v0.3 (`io.a2a.*`) path, JVM-only.
57+
// jvmMain hosts the deprecated v0.3 path (JVM-only); androidMain stays v1.0-only.
4658
val jvmMain by getting {
4759
dependsOn(commonJvmAndroidMain)
4860
dependencies {
61+
// Only the deprecated v0.3 (JVM-only) converters use jackson-module-kotlin; keep it off the
62+
// Android path, which serializes via kotlinx.serialization instead.
4963
implementation(libs.jackson.module.kotlin)
5064
implementation(libs.a2a.legacy.sdk.client)
5165
implementation(libs.a2a.legacy.sdk.common)
@@ -58,19 +72,26 @@ kotlin {
5872
implementation(libs.google.truth)
5973
implementation(libs.mockito.kotlin)
6074
implementation(libs.kotlinx.coroutines.test)
75+
implementation(libs.okhttp.mockwebserver)
6176
implementation(libs.a2a.legacy.sdk.client)
6277
implementation(libs.a2a.legacy.sdk.spec)
6378
}
6479
}
80+
val androidMain by getting {
81+
dependsOn(commonJvmAndroidMain)
82+
dependencies { implementation(libs.a2a.sdk.http.client.android) }
83+
}
6584
}
6685
}
6786

6887
// Coordinates the Kotlin Multiplatform plugin uses for the publications it
6988
// auto-creates:
70-
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
71-
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
72-
// POM metadata, Dokka javadoc, and GPG signing are configured in the root
73-
// build.gradle.kts.
89+
// - `kotlinMultiplatform` -> google-adk-kotlin-a2a (root metadata)
90+
// - `jvm` -> google-adk-kotlin-a2a-jvm (KMP target)
91+
// - `androidRelease` -> google-adk-kotlin-a2a-android (KMP target)
92+
// Per-target suffixes (`-jvm`, `-android`) are appended by the KMP plugin
93+
// automatically. POM metadata, Dokka javadoc, and GPG signing are configured in
94+
// the root build.gradle.kts.
7495
publishing {
7596
publications.withType<MavenPublication>().configureEach {
7697
if (name == "kotlinMultiplatform") {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!-- Android manifest for the A2A instrumented test app. -->
2+
3+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
4+
package="com.google.adk.kt.a2a.instrumentation">
5+
6+
<uses-sdk android:minSdkVersion="26" />
7+
<uses-permission android:name="android.permission.INTERNET" />
8+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
9+
10+
<!-- Cleartext is needed only because the test talks to a local http MockWebServer; a real A2A
11+
server uses https, so this is a test-only allowance (real Android blocks cleartext by default,
12+
which Robolectric does not enforce). -->
13+
<application android:usesCleartextTraffic="true">
14+
<uses-library android:name="android.test.runner" />
15+
</application>
16+
17+
<instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
18+
android:targetPackage="com.google.adk.kt.a2a.instrumentation" />
19+
20+
</manifest>
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.a2a.agent
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.google.adk.kt.a2a.android.androidA2AAgent
21+
import com.google.adk.kt.agents.InvocationContext
22+
import com.google.adk.kt.events.Event
23+
import com.google.adk.kt.sessions.Session
24+
import com.google.adk.kt.sessions.SessionKey
25+
import com.google.adk.kt.testing.DummyAgent
26+
import com.google.adk.kt.testing.userMessage
27+
import com.google.common.truth.Truth.assertThat
28+
import kotlinx.coroutines.flow.toList
29+
import kotlinx.coroutines.test.runTest
30+
import okhttp3.mockwebserver.MockResponse
31+
import okhttp3.mockwebserver.MockWebServer
32+
import okhttp3.mockwebserver.RecordedRequest
33+
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil
34+
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageResponse
35+
import org.a2aproject.sdk.spec.AgentCapabilities
36+
import org.a2aproject.sdk.spec.AgentCard
37+
import org.a2aproject.sdk.spec.AgentInterface
38+
import org.a2aproject.sdk.spec.Message
39+
import org.a2aproject.sdk.spec.Task
40+
import org.a2aproject.sdk.spec.TaskState
41+
import org.a2aproject.sdk.spec.TaskStatus
42+
import org.a2aproject.sdk.spec.TextPart
43+
import org.a2aproject.sdk.spec.TransportProtocol
44+
import org.junit.After
45+
import org.junit.Before
46+
import org.junit.Test
47+
import org.junit.runner.RunWith
48+
49+
/**
50+
* On-device (ART) counterpart of `A2AAgentAndroidTest`: builds an Android A2A agent via
51+
* [androidA2AAgent] and runs a round-trip against an in-process [MockWebServer], exercising the
52+
* proto-free Android transport.
53+
*
54+
* The MockWebServer returns a real JSON-RPC response built with the SDK's own
55+
* [JsonUtil] + [SendMessageResponse], and the transport parses it back with [JsonUtil], so this
56+
* exercises the actual proto-free A2A serialization on a real Android runtime in both directions.
57+
*/
58+
@RunWith(AndroidJUnit4::class)
59+
class A2AAgentInstrumentationTest {
60+
61+
private lateinit var server: MockWebServer
62+
63+
@Before
64+
fun setUp() {
65+
server = MockWebServer()
66+
server.start()
67+
}
68+
69+
@After
70+
fun tearDown() {
71+
server.shutdown()
72+
}
73+
74+
@Test
75+
fun runAsync_androidHttpClient_realRoundTrip_emitsAgentEventAndSendsRequest() = runTest {
76+
val agentReply = "Hello from the Android A2A agent!"
77+
78+
// Build a real JSON-RPC `message/send` response with the SDK's own proto-free serialization:
79+
// a completed Task whose status message carries the agent text. Using a completed Task (rather
80+
// than a bare Message) lets the agent's non-streaming flow recognise the turn as terminal.
81+
val agentMessage =
82+
Message.builder()
83+
.messageId("agent-message-1")
84+
.role(Message.Role.ROLE_AGENT)
85+
.parts(listOf(TextPart(agentReply)))
86+
.build()
87+
val responseTask =
88+
Task.builder()
89+
.id("android-task-1")
90+
.contextId("android-context-1")
91+
.status(TaskStatus(TaskState.TASK_STATE_COMPLETED, agentMessage, null))
92+
.build()
93+
val responseBody = JsonUtil.toJson(SendMessageResponse("2.0", "1", responseTask, null))
94+
server.enqueue(MockResponse().setBody(responseBody))
95+
val serverUrl = server.url("/a2a").toString()
96+
97+
val agentCard =
98+
AgentCard.builder()
99+
.name("remote-agent")
100+
.description("Remote Agent")
101+
.url(serverUrl)
102+
.version("1.0.0")
103+
.defaultInputModes(listOf("text"))
104+
.defaultOutputModes(listOf("text"))
105+
.skills(listOf())
106+
.supportedInterfaces(
107+
listOf(AgentInterface(TransportProtocol.JSONRPC.asString(), serverUrl))
108+
)
109+
.capabilities(AgentCapabilities.builder().streaming(false).build())
110+
.build()
111+
112+
val agent = androidA2AAgent(name = "remote-agent", agentCard = agentCard, streaming = false)
113+
114+
val session =
115+
Session(
116+
key = SessionKey(appName = "demo", userId = "user", id = "session-1"),
117+
events =
118+
mutableListOf(
119+
Event(invocationId = "invocation-0", author = "user", content = userMessage("hello"))
120+
),
121+
)
122+
val context = InvocationContext(agent = DummyAgent(), session = session, runConfig = null)
123+
124+
val events = agent.runAsync(context).toList()
125+
126+
// The agent emitted an ADK Event carrying the agent text from the real round-trip.
127+
val emittedTexts = events.mapNotNull { it.content?.parts?.firstOrNull()?.text }
128+
assertThat(emittedTexts).contains(agentReply)
129+
130+
// The user message actually traversed AndroidA2AHttpClient and reached the server.
131+
val recorded = server.takeRecordedRequestOrFail()
132+
assertThat(recorded.path).isEqualTo("/a2a")
133+
assertThat(recorded.method).isEqualTo("POST")
134+
assertThat(recorded.body.readUtf8()).contains("hello")
135+
}
136+
137+
@Test
138+
fun runAsync_autoFetchCard_realRoundTrip_fetchesCardThenSendsMessage() = runTest {
139+
val agentReply = "Hello from the auto-fetched Android A2A agent!"
140+
val serverUrl = server.url("/a2a").toString()
141+
142+
val agentCard =
143+
AgentCard.builder()
144+
.name("remote-agent")
145+
.description("Remote Agent")
146+
.url(serverUrl)
147+
.version("1.0.0")
148+
.defaultInputModes(listOf("text"))
149+
.defaultOutputModes(listOf("text"))
150+
.skills(listOf())
151+
.supportedInterfaces(
152+
listOf(AgentInterface(TransportProtocol.JSONRPC.asString(), serverUrl))
153+
)
154+
.capabilities(AgentCapabilities.builder().streaming(false).build())
155+
.build()
156+
// 1) Served for the auto-fetch GET of `/.well-known/agent-card.json`.
157+
server.enqueue(MockResponse().setBody(JsonUtil.toJson(agentCard)))
158+
159+
val agentMessage =
160+
Message.builder()
161+
.messageId("agent-message-1")
162+
.role(Message.Role.ROLE_AGENT)
163+
.parts(listOf(TextPart(agentReply)))
164+
.build()
165+
val responseTask =
166+
Task.builder()
167+
.id("android-task-1")
168+
.contextId("android-context-1")
169+
.status(TaskStatus(TaskState.TASK_STATE_COMPLETED, agentMessage, null))
170+
.build()
171+
// 2) Served for the `message/send` POST.
172+
server.enqueue(
173+
MockResponse().setBody(JsonUtil.toJson(SendMessageResponse("2.0", "1", responseTask, null)))
174+
)
175+
176+
// No card supplied: the agent auto-fetches it from the well-known endpoint on-device.
177+
val agent =
178+
androidA2AAgent(
179+
name = "remote-agent",
180+
agentCardUrl = server.url("/").toString(),
181+
streaming = false,
182+
)
183+
184+
val session =
185+
Session(
186+
key = SessionKey(appName = "demo", userId = "user", id = "session-1"),
187+
events =
188+
mutableListOf(
189+
Event(invocationId = "invocation-0", author = "user", content = userMessage("hello"))
190+
),
191+
)
192+
val context = InvocationContext(agent = DummyAgent(), session = session, runConfig = null)
193+
194+
val events = agent.runAsync(context).toList()
195+
196+
val emittedTexts = events.mapNotNull { it.content?.parts?.firstOrNull()?.text }
197+
assertThat(emittedTexts).contains(agentReply)
198+
199+
// The card was fetched from the well-known endpoint first, then the message was sent.
200+
val cardRequest = server.takeRecordedRequestOrFail()
201+
assertThat(cardRequest.path).isEqualTo("/.well-known/agent-card.json")
202+
assertThat(cardRequest.method).isEqualTo("GET")
203+
val sendRequest = server.takeRecordedRequestOrFail()
204+
assertThat(sendRequest.path).isEqualTo("/a2a")
205+
assertThat(sendRequest.method).isEqualTo("POST")
206+
assertThat(sendRequest.body.readUtf8()).contains("hello")
207+
}
208+
}
209+
210+
private fun MockWebServer.takeRecordedRequestOrFail(): RecordedRequest =
211+
try {
212+
takeRequest()
213+
} catch (e: InterruptedException) {
214+
Thread.currentThread().interrupt()
215+
throw AssertionError("Interrupted while waiting for the recorded request", e)
216+
}

0 commit comments

Comments
 (0)