From fcfd9bd8b1b5516932b9c5d72a62a191aea88e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sobczyk?= Date: Wed, 5 Aug 2026 05:52:52 -0700 Subject: [PATCH] fix(a2a): guard null DataPart metadata in ResponseConverter `ResponseConverter.getLongRunningToolIds` dereferenced `DataPart.getMetadata()` without a null check, but the A2A spec treats `metadata` as optional and deserializes an absent or null field to `null`. Any peer returning such a `DataPart` in a task artifact or status message triggered an NPE that aborted the entire response conversion and failed the local agent's turn. Skip the part when metadata is missing, and cover all three call sites with regression tests. Malformed rather than absent peer metadata can still abort conversion through `parseMetadata`. That is a different trigger with a different fix, tracked separately in b/542523886. PiperOrigin-RevId: 959608801 --- .../adk/a2a/converters/ResponseConverter.java | 10 ++- .../a2a/converters/ResponseConverterTest.java | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java index cffd76983..eb670bb79 100644 --- a/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java +++ b/a2a/src/main/java/com/google/adk/a2a/converters/ResponseConverter.java @@ -91,6 +91,11 @@ private static boolean isPartial(@Nullable Map metadata) { return Objects.equals(metadata.getOrDefault(A2AMetadataKey.PARTIAL.getType(), false), true); } + private static boolean isLongRunning(@Nullable Map metadata) { + return metadata != null + && Objects.equals(metadata.get(A2AMetadataKey.IS_LONG_RUNNING.getType()), true); + } + /** * Converts a A2A {@link TaskUpdateEvent} to an ADK {@link Event}, if applicable. Returns null if * the event is not a final update for TaskArtifactUpdateEvent or if the message is empty for @@ -266,9 +271,8 @@ private static ImmutableSet getLongRunningToolIds( if (!(part instanceof DataPart dataPart)) { return Optional.empty(); } - Object isLongRunning = - dataPart.getMetadata().get(A2AMetadataKey.IS_LONG_RUNNING.getType()); - if (!Objects.equals(isLongRunning, true)) { + // A2A peers may omit metadata entirely, which deserializes to null. + if (!isLongRunning(dataPart.getMetadata())) { return Optional.empty(); } if (convertedPart.functionCall().isEmpty()) { diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java index 20f2d3c10..5f0f07dec 100644 --- a/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/converters/ResponseConverterTest.java @@ -47,6 +47,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.reactivex.rxjava3.core.Flowable; +import java.util.List; import java.util.Optional; import org.junit.Before; import org.junit.Test; @@ -272,6 +273,79 @@ public void taskToEvent_withInputRequired_parsesLongRunningToolIds() { assertThat(event.longRunningToolIds().get()).containsExactly("call_123", "msg_123"); } + @Test + public void taskToEvent_withDataPartWithoutMetadata_doesNotThrow() { + DataPart dataPart = + new DataPart( + ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of())); + DataPart statusDataPart = + new DataPart( + ImmutableMap.of("name", "messageTool", "id", "msg_123", "args", ImmutableMap.of())); + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(statusDataPart)) + .build(); + TaskStatus status = new TaskStatus(TaskState.INPUT_REQUIRED, statusMessage, null); + Artifact artifact = + new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(dataPart)).build(); + Task task = testTask().status(status).artifacts(ImmutableList.of(artifact)).build(); + + Event event = ResponseConverter.taskToEvent(task, invocationContext); + + assertThat(event.longRunningToolIds().get()).isEmpty(); + List parts = event.content().get().parts().get(); + assertThat(parts).hasSize(2); + assertThat(parts.get(0).functionCall().get().id()).hasValue("call_123"); + assertThat(parts.get(1).functionCall().get().id()).hasValue("msg_123"); + } + + @Test + public void artifactToEvent_withDataPartWithoutMetadata_doesNotThrow() { + DataPart dataPart = + new DataPart( + ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of())); + Artifact artifact = + new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(dataPart)).build(); + + Event event = ResponseConverter.artifactToEvent(artifact, invocationContext); + + assertThat(event.longRunningToolIds().get()).isEmpty(); + List parts = event.content().get().parts().get(); + assertThat(parts).hasSize(1); + assertThat(parts.get(0).functionCall().get().id()).hasValue("call_123"); + } + + @Test + public void taskToEvent_withMixedMetadataParts_keepsLongRunningId() { + DataPart noMetadataPart = + new DataPart( + ImmutableMap.of("name", "plainTool", "id", "call_plain", "args", ImmutableMap.of())); + DataPart longRunningPart = + new DataPart( + ImmutableMap.of("name", "lrTool", "id", "call_lr", "args", ImmutableMap.of()), + ImmutableMap.of( + A2AMetadataKey.TYPE.getType(), + "function_call", + A2AMetadataKey.IS_LONG_RUNNING.getType(), + true)); + Artifact artifact = + new Artifact.Builder() + .artifactId("artifact-1") + .parts(ImmutableList.of(noMetadataPart, longRunningPart)) + .build(); + Task task = + testTask() + .status(new TaskStatus(TaskState.INPUT_REQUIRED, null, null)) + .artifacts(ImmutableList.of(artifact)) + .build(); + + Event event = ResponseConverter.taskToEvent(task, invocationContext); + + assertThat(event.longRunningToolIds().get()).containsExactly("call_lr"); + assertThat(event.content().get().parts().get()).hasSize(2); + } + @Test public void taskToEvent_withFailedState_setsErrorCode() { Message statusMessage =