From e51f9112050955657da0dfc3aedc00f90ad739ec Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 18 Mar 2026 05:37:53 -0700 Subject: [PATCH] feat: add handling the a2a metadata in the RemoteA2AAgent; Add the enum type for the metadata keys PiperOrigin-RevId: 885539894 --- .../adk/a2a/converters/A2AMetadataKey.java | 40 ++++ .../adk/a2a/converters/AdkMetadataKey.java | 35 ++++ .../adk/a2a/converters/PartConverter.java | 19 +- .../adk/a2a/converters/ResponseConverter.java | 131 +++++++++++-- .../adk/a2a/converters/PartConverterTest.java | 50 ++++- .../a2a/converters/ResponseConverterTest.java | 175 +++++++++++++++++- 6 files changed, 408 insertions(+), 42 deletions(-) create mode 100644 a2a/src/main/java/com/google/adk/a2a/converters/A2AMetadataKey.java create mode 100644 a2a/src/main/java/com/google/adk/a2a/converters/AdkMetadataKey.java diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/A2AMetadataKey.java b/a2a/src/main/java/com/google/adk/a2a/converters/A2AMetadataKey.java new file mode 100644 index 000000000..d4f1fef58 --- /dev/null +++ b/a2a/src/main/java/com/google/adk/a2a/converters/A2AMetadataKey.java @@ -0,0 +1,40 @@ +/* + * 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.a2a.converters; + +/** + * Enum for the type of A2A metadata. Adds a prefix used to differentiage ADK-related values stored + * in Metadata an A2A event. + */ +public enum A2AMetadataKey { + TYPE("type"), + IS_LONG_RUNNING("is_long_running"), + PARTIAL("partial"), + GROUNDING_METADATA("grounding_metadata"), + USAGE_METADATA("usage_metadata"), + CUSTOM_METADATA("custom_metadata"), + ERROR_CODE("error_code"); + + private final String type; + + private A2AMetadataKey(String type) { + this.type = "adk_" + type; + } + + public String getType() { + return type; + } +} diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/AdkMetadataKey.java b/a2a/src/main/java/com/google/adk/a2a/converters/AdkMetadataKey.java new file mode 100644 index 000000000..e38f28828 --- /dev/null +++ b/a2a/src/main/java/com/google/adk/a2a/converters/AdkMetadataKey.java @@ -0,0 +1,35 @@ +/* + * 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.a2a.converters; + +/** + * Enum for the type of ADK metadata. Adds a prefix used to differentiate A2A-related values stored + * in custom metadata of an ADK session event. + */ +public enum AdkMetadataKey { + TASK_ID("task_id"), + CONTEXT_ID("context_id"); + + private final String type; + + private AdkMetadataKey(String type) { + this.type = "a2a:" + type; + } + + public String getType() { + return type; + } +} diff --git a/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java b/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java index 61f24fa21..714a79736 100644 --- a/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java +++ b/a2a/src/main/java/com/google/adk/a2a/converters/PartConverter.java @@ -52,11 +52,7 @@ public final class PartConverter { private static final Logger logger = LoggerFactory.getLogger(PartConverter.class); private static final ObjectMapper objectMapper = new ObjectMapper(); - // Constants for metadata types. By convention metadata keys are prefixed with "adk_" to align - // with the Python and Golang libraries. - public static final String A2A_DATA_PART_METADATA_TYPE_KEY = "adk_type"; - public static final String A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = "adk_is_long_running"; - public static final String A2A_DATA_PART_METADATA_IS_PARTIAL_KEY = "adk_partial"; + // Constants for metadata types. public static final String LANGUAGE_KEY = "language"; public static final String OUTCOME_KEY = "outcome"; public static final String CODE_KEY = "code"; @@ -135,7 +131,7 @@ private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart d Map metadata = Optional.ofNullable(dataPart.getMetadata()).map(HashMap::new).orElseGet(HashMap::new); - String metadataType = metadata.getOrDefault(A2A_DATA_PART_METADATA_TYPE_KEY, "").toString(); + String metadataType = metadata.getOrDefault(A2AMetadataKey.TYPE.getType(), "").toString(); if ((data.containsKey(NAME_KEY) && data.containsKey(ARGS_KEY)) || metadataType.equals(A2ADataPartMetadataType.FUNCTION_CALL.getType())) { @@ -218,7 +214,7 @@ private static DataPart createDataPartFromFunctionCall( addValueIfPresent(data, WILL_CONTINUE_KEY, functionCall.willContinue()); addValueIfPresent(data, PARTIAL_ARGS_KEY, functionCall.partialArgs()); - metadata.put(A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_CALL.getType()); + metadata.put(A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType()); return new DataPart(data.buildOrThrow(), metadata.buildOrThrow()); } @@ -245,7 +241,7 @@ private static DataPart createDataPartFromFunctionResponse( addValueIfPresent(data, PARTS_KEY, functionResponse.parts()); metadata.put( - A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_RESPONSE.getType()); + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_RESPONSE.getType()); return new DataPart(data.buildOrThrow(), metadata.buildOrThrow()); } @@ -268,7 +264,7 @@ private static DataPart createDataPartFromCodeExecutionResult( addValueIfPresent(data, OUTPUT_KEY, codeExecutionResult.output()); metadata.put( - A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType()); + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.CODE_EXECUTION_RESULT.getType()); return new DataPart(data.buildOrThrow(), metadata.buildOrThrow()); } @@ -290,8 +286,7 @@ private static DataPart createDataPartFromExecutableCode( .orElse(Language.Known.LANGUAGE_UNSPECIFIED.toString())); addValueIfPresent(data, CODE_KEY, executableCode.code()); - metadata.put( - A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.EXECUTABLE_CODE.getType()); + metadata.put(A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.EXECUTABLE_CODE.getType()); return new DataPart(data.buildOrThrow(), metadata.buildOrThrow()); } @@ -305,7 +300,7 @@ public static io.a2a.spec.Part fromGenaiPart(Part part, boolean isPartial) { } ImmutableMap.Builder metadata = ImmutableMap.builder(); if (isPartial) { - metadata.put(A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, true); + metadata.put(A2AMetadataKey.PARTIAL.getType(), true); } if (part.text().isPresent()) { 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 503432a30..cffd76983 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 @@ -19,12 +19,20 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.common.collect.Streams.zip; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.agents.InvocationContext; import com.google.adk.events.Event; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.genai.types.Content; +import com.google.genai.types.CustomMetadata; +import com.google.genai.types.FinishReason; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GroundingMetadata; import com.google.genai.types.Part; import io.a2a.client.ClientEvent; import io.a2a.client.MessageEvent; @@ -43,11 +51,13 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Utility for converting ADK events to A2A spec messages (and back). */ public final class ResponseConverter { + private static final ObjectMapper objectMapper = new ObjectMapper(); private static final Logger logger = LoggerFactory.getLogger(ResponseConverter.class); private static final ImmutableSet PENDING_STATES = ImmutableSet.of(TaskState.WORKING, TaskState.SUBMITTED); @@ -74,12 +84,11 @@ public static Optional clientEventToEvent( throw new IllegalArgumentException("Unsupported ClientEvent type: " + event.getClass()); } - private static boolean isPartial(Map metadata) { + private static boolean isPartial(@Nullable Map metadata) { if (metadata == null) { return false; } - return Objects.equals( - metadata.getOrDefault(PartConverter.A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, false), true); + return Objects.equals(metadata.getOrDefault(A2AMetadataKey.PARTIAL.getType(), false), true); } /** @@ -110,7 +119,12 @@ private static Optional handleTaskUpdate( // append=false, lastChunk=false: emit as partial, reset aggregation // append=true, lastChunk=true: emit as partial, update aggregation and emit as non-partial // append=false, lastChunk=true: emit as non-partial, drop aggregation - return Optional.of(eventPart); + return Optional.of( + updateEventMetadata( + eventPart, + artifactEvent.getMetadata(), + artifactEvent.getTaskId(), + artifactEvent.getContextId())); } if (updateEvent instanceof TaskStatusUpdateEvent statusEvent) { @@ -128,14 +142,21 @@ private static Optional handleTaskUpdate( }); if (statusEvent.isFinal()) { - return messageEvent - .map(Event::toBuilder) - .or(() -> Optional.of(remoteAgentEventBuilder(context))) - .map(builder -> builder.turnComplete(true)) - .map(builder -> builder.partial(false)) - .map(Event.Builder::build); + messageEvent = + messageEvent + .map(Event::toBuilder) + .or(() -> Optional.of(remoteAgentEventBuilder(context))) + .map(builder -> builder.turnComplete(true)) + .map(builder -> builder.partial(false)) + .map(Event.Builder::build); } - return messageEvent; + return messageEvent.map( + finalMessageEvent -> + updateEventMetadata( + finalMessageEvent, + statusEvent.getMetadata(), + statusEvent.getTaskId(), + statusEvent.getContextId())); } throw new IllegalArgumentException( "Unsupported TaskUpdateEvent type: " + updateEvent.getClass()); @@ -163,9 +184,13 @@ public static Event messageToFailedEvent(Message message, InvocationContext invo /** Converts an A2A message back to ADK events. */ public static Event messageToEvent(Message message, InvocationContext invocationContext) { - return remoteAgentEventBuilder(invocationContext) - .content(fromModelParts(PartConverter.toGenaiParts(message.getParts()))) - .build(); + return updateEventMetadata( + remoteAgentEventBuilder(invocationContext) + .content(fromModelParts(PartConverter.toGenaiParts(message.getParts()))) + .build(), + message.getMetadata(), + message.getTaskId(), + message.getContextId()); } /** @@ -228,7 +253,8 @@ public static Event taskToEvent(Task task, InvocationContext invocationContext) eventBuilder.longRunningToolIds(longRunningToolIds.build()); } eventBuilder.turnComplete(isFinal); - return eventBuilder.build(); + return updateEventMetadata( + eventBuilder.build(), task.getMetadata(), task.getId(), task.getContextId()); } private static ImmutableSet getLongRunningToolIds( @@ -241,9 +267,7 @@ private static ImmutableSet getLongRunningToolIds( return Optional.empty(); } Object isLongRunning = - dataPart - .getMetadata() - .get(PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY); + dataPart.getMetadata().get(A2AMetadataKey.IS_LONG_RUNNING.getType()); if (!Objects.equals(isLongRunning, true)) { return Optional.empty(); } @@ -256,6 +280,77 @@ private static ImmutableSet getLongRunningToolIds( .collect(toImmutableSet()); } + private static Event updateEventMetadata( + Event event, + @Nullable Map clientMetadata, + @Nullable String taskId, + @Nullable String contextId) { + if (taskId == null || contextId == null) { + logger.warn("Task ID or context ID is null, skipping metadata update."); + return event; + } + + if (clientMetadata == null) { + clientMetadata = ImmutableMap.of(); + } + Event.Builder eventBuilder = event.toBuilder(); + Object groundingMetadata = clientMetadata.get(A2AMetadataKey.GROUNDING_METADATA.getType()); + // if groundingMetadata is null, parseMetadata will return null as well. + eventBuilder.groundingMetadata(parseMetadata(groundingMetadata, GroundingMetadata.class)); + Object usageMetadata = clientMetadata.get(A2AMetadataKey.USAGE_METADATA.getType()); + // if usageMetadata is null, parseMetadata will return null as well. + eventBuilder.usageMetadata( + parseMetadata(usageMetadata, GenerateContentResponseUsageMetadata.class)); + + ImmutableList.Builder customMetadataList = ImmutableList.builder(); + customMetadataList + .add( + CustomMetadata.builder() + .key(AdkMetadataKey.TASK_ID.getType()) + .stringValue(taskId) + .build()) + .add( + CustomMetadata.builder() + .key(AdkMetadataKey.CONTEXT_ID.getType()) + .stringValue(contextId) + .build()); + Object customMetadata = clientMetadata.get(A2AMetadataKey.CUSTOM_METADATA.getType()); + if (customMetadata != null) { + customMetadataList.addAll( + parseMetadata(customMetadata, new TypeReference>() {})); + } + eventBuilder.customMetadata(customMetadataList.build()); + + Object errorCode = clientMetadata.get(A2AMetadataKey.ERROR_CODE.getType()); + eventBuilder.errorCode(parseMetadata(errorCode, FinishReason.class)); + + return eventBuilder.build(); + } + + private static @Nullable T parseMetadata(@Nullable Object metadata, Class type) { + try { + if (metadata instanceof String jsonString) { + return objectMapper.readValue(jsonString, type); + } else { + return objectMapper.convertValue(metadata, type); + } + } catch (IllegalArgumentException | JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse metadata of type " + type, e); + } + } + + private static @Nullable T parseMetadata(@Nullable Object metadata, TypeReference type) { + try { + if (metadata instanceof String jsonString) { + return objectMapper.readValue(jsonString, type); + } else { + return objectMapper.convertValue(metadata, type); + } + } catch (IllegalArgumentException | JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse metadata of type " + type.getType(), e); + } + } + private static Event emptyEvent(InvocationContext invocationContext) { Event.Builder builder = Event.builder() diff --git a/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java b/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java index d93466dd2..4a0828c43 100644 --- a/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java +++ b/a2a/src/test/java/com/google/adk/a2a/converters/PartConverterTest.java @@ -8,9 +8,13 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.Blob; +import com.google.genai.types.CodeExecutionResult; +import com.google.genai.types.ExecutableCode; import com.google.genai.types.FileData; import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionResponse; +import com.google.genai.types.Language; +import com.google.genai.types.Outcome; import com.google.genai.types.Part; import io.a2a.spec.DataPart; import io.a2a.spec.FilePart; @@ -86,8 +90,7 @@ public void toGenaiPart_withDataPartFunctionCall_returnsGenaiFunctionCallPart() new DataPart( data, ImmutableMap.of( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, - A2ADataPartMetadataType.FUNCTION_CALL.getType())); + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType())); Part result = PartConverter.toGenaiPart(dataPart); @@ -121,7 +124,7 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons new DataPart( data, ImmutableMap.of( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_RESPONSE.getType())); Part result = PartConverter.toGenaiPart(dataPart); @@ -188,7 +191,7 @@ public void fromGenaiPart_withTextPart_returnsTextPart() { assertThat(((TextPart) result).getText()).isEqualTo("text"); assertThat(((TextPart) result).getMetadata()).containsEntry("thought", true); assertThat(((TextPart) result).getMetadata()) - .containsEntry(PartConverter.A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, true); + .containsEntry(A2AMetadataKey.PARTIAL.getType(), true); } @Test @@ -226,6 +229,39 @@ public void fromGenaiPart_withInlineDataPart_returnsFilePartWithBytes() { assertThat(Base64.getDecoder().decode(fileWithBytes.bytes())).isEqualTo(bytes); } + @Test + public void fromGenaiPart_dataPart_executableCode_returnsDataPart() { + ExecutableCode executableCode = + ExecutableCode.builder().code("print('hello')").language(new Language("python")).build(); + Part part = Part.builder().executableCode(executableCode).build(); + io.a2a.spec.Part result = PartConverter.fromGenaiPart(part, false); + + assertThat(result).isInstanceOf(DataPart.class); + DataPart dataPart = (DataPart) result; + assertThat(dataPart.getData().get("code")).isEqualTo("print('hello')"); + assertThat(dataPart.getData().get("language")).isEqualTo("python"); + assertThat(dataPart.getMetadata().get(A2AMetadataKey.TYPE.getType())) + .isEqualTo("executable_code"); + } + + @Test + public void fromGenaiPart_dataPart_codeExecutionResult_returnsDataPart() { + CodeExecutionResult codeExecutionResult = + CodeExecutionResult.builder() + .outcome(new Outcome("OUTCOME_OK")) + .output("print('hello')") + .build(); + Part part = Part.builder().codeExecutionResult(codeExecutionResult).build(); + io.a2a.spec.Part result = PartConverter.fromGenaiPart(part, false); + + assertThat(result).isInstanceOf(DataPart.class); + DataPart dataPart = (DataPart) result; + assertThat(dataPart.getData().get("outcome")).isEqualTo("OUTCOME_OK"); + assertThat(dataPart.getData().get("output")).isEqualTo("print('hello')"); + assertThat(dataPart.getMetadata().get(A2AMetadataKey.TYPE.getType())) + .isEqualTo("code_execution_result"); + } + @Test public void fromGenaiPart_withFunctionCallPart_returnsDataPart() { Part part = @@ -255,8 +291,7 @@ public void fromGenaiPart_withFunctionCallPart_returnsDataPart() { true); assertThat(dataPart.getMetadata()) .containsEntry( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, - A2ADataPartMetadataType.FUNCTION_CALL.getType()); + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_CALL.getType()); } @Test @@ -275,8 +310,7 @@ public void fromGenaiPart_withFunctionResponsePart_returnsDataPart() { .containsExactly("name", "func", "id", "1", "response", ImmutableMap.of()); assertThat(dataPart.getMetadata()) .containsEntry( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, - A2ADataPartMetadataType.FUNCTION_RESPONSE.getType()); + A2AMetadataKey.TYPE.getType(), A2ADataPartMetadataType.FUNCTION_RESPONSE.getType()); } @Test 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 d84dc42cd..b61b00e1a 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 @@ -1,6 +1,8 @@ package com.google.adk.a2a.converters; import static com.google.common.truth.Truth.assertThat; +import static java.util.stream.Collectors.joining; +import static org.junit.Assert.assertThrows; import com.google.adk.agents.BaseAgent; import com.google.adk.agents.InvocationContext; @@ -13,6 +15,10 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.genai.types.Content; +import com.google.genai.types.CustomMetadata; +import com.google.genai.types.FinishReason; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GroundingMetadata; import io.a2a.client.MessageEvent; import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.Artifact; @@ -136,6 +142,74 @@ public void taskToEvent_withStatusMessage_returnsEvent() { assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message"); } + @Test + public void taskToEvent_withGroundingMetadata_returnsEvent() { + GroundingMetadata groundingMetadata = + GroundingMetadata.builder().webSearchQueries("test-query").build(); + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Status message"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null); + Task task = + testTask() + .status(status) + .artifacts(null) + .metadata( + ImmutableMap.of( + A2AMetadataKey.GROUNDING_METADATA.getType(), groundingMetadata.toJson())) + .build(); + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message"); + assertThat(event.groundingMetadata()).hasValue(groundingMetadata); + } + + @Test + public void taskToEvent_withCustomMetadata_returnsEvent() { + ImmutableList customMetadataList = + ImmutableList.of( + CustomMetadata.builder().key("test-key").stringValue("test-value").build()); + String customMetadataJson = + customMetadataList.stream().map(CustomMetadata::toJson).collect(joining(",", "[", "]")); + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Status message"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null); + Task task = + testTask() + .status(status) + .artifacts(null) + .metadata(ImmutableMap.of(A2AMetadataKey.CUSTOM_METADATA.getType(), customMetadataJson)) + .build(); + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.content().get().parts().get().get(0).text()).hasValue("Status message"); + assertThat(event.customMetadata().get()) + .containsExactly( + CustomMetadata.builder().key("a2a:task_id").stringValue("task-1").build(), + CustomMetadata.builder().key("a2a:context_id").stringValue("context-1").build(), + CustomMetadata.builder().key("test-key").stringValue("test-value").build()) + .inOrder(); + } + + @Test + public void messageToEvent_withMissingTaskId_returnsEvent() { + Message a2aMessage = + new Message.Builder() + .messageId("msg-1") + .role(Message.Role.USER) + .taskId("task-1") + .parts(ImmutableList.of(new TextPart("test-message"))) + .build(); + Event event = ResponseConverter.messageToEvent(a2aMessage, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.customMetadata()).isEmpty(); + } + @Test public void taskToEvent_withNoMessage_returnsEmptyEvent() { TaskStatus status = new TaskStatus(TaskState.WORKING, null, null); @@ -152,18 +226,18 @@ public void taskToEvent_withInputRequired_parsesLongRunningToolIds() { ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of()); ImmutableMap metadata = ImmutableMap.of( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, + A2AMetadataKey.TYPE.getType(), "function_call", - PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, + A2AMetadataKey.IS_LONG_RUNNING.getType(), true); DataPart dataPart = new DataPart(data, metadata); ImmutableMap statusData = ImmutableMap.of("name", "messageTools", "id", "msg_123", "args", ImmutableMap.of()); ImmutableMap statusMetadata = ImmutableMap.of( - PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, + A2AMetadataKey.TYPE.getType(), "function_call", - PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, + A2AMetadataKey.IS_LONG_RUNNING.getType(), true); DataPart statusDataPart = new DataPart(statusData, statusMetadata); Message statusMessage = @@ -361,6 +435,99 @@ public void clientEventToEvent_withFailedTaskStatusUpdateEvent_returnsErrorEvent assertThat(resultEvent.turnComplete()).hasValue(true); } + @Test + public void taskToEvent_withInvalidMetadata_throwsException() { + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Status message"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null); + Task task = + testTask() + .status(status) + .artifacts(null) + .metadata( + ImmutableMap.of(A2AMetadataKey.GROUNDING_METADATA.getType(), "{ invalid json ]")) + .build(); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> ResponseConverter.taskToEvent(task, invocationContext)); + assertThat(exception).hasMessageThat().contains("Failed to parse metadata"); + assertThat(exception).hasMessageThat().contains("GroundingMetadata"); + } + + @Test + public void taskToEvent_withErrorCode_returnsEvent() { + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Status message"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null); + Task task = + testTask() + .status(status) + .artifacts(null) + .metadata(ImmutableMap.of(A2AMetadataKey.ERROR_CODE.getType(), "\"STOP\"")) + .build(); + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.errorCode()).hasValue(new FinishReason(FinishReason.Known.STOP)); + } + + @Test + public void taskToEvent_withUsageMetadata_returnsEvent() { + GenerateContentResponseUsageMetadata usageMetadata = + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(10) + .candidatesTokenCount(20) + .totalTokenCount(30) + .build(); + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Status message"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.WORKING, statusMessage, null); + Task task = + testTask() + .status(status) + .artifacts(null) + .metadata( + ImmutableMap.of(A2AMetadataKey.USAGE_METADATA.getType(), usageMetadata.toJson())) + .build(); + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.usageMetadata()).hasValue(usageMetadata); + } + + @Test + public void clientEventToEvent_withTaskArtifactUpdateEventAndPartialTrue_returnsEmpty() { + io.a2a.spec.Part a2aPart = new TextPart("Artifact content"); + Artifact artifact = + new Artifact.Builder().artifactId("artifact-1").parts(ImmutableList.of(a2aPart)).build(); + Task task = + testTask() + .status(new TaskStatus(TaskState.COMPLETED)) + .artifacts(ImmutableList.of(artifact)) + .build(); + TaskArtifactUpdateEvent updateEvent = + new TaskArtifactUpdateEvent.Builder() + .lastChunk(true) + .metadata(ImmutableMap.of(A2AMetadataKey.PARTIAL.getType(), true)) + .contextId("context-1") + .artifact(artifact) + .taskId("task-id-1") + .build(); + TaskUpdateEvent event = new TaskUpdateEvent(task, updateEvent); + + Optional optionalEvent = ResponseConverter.clientEventToEvent(event, invocationContext); + assertThat(optionalEvent).isEmpty(); + } + private static final class TestAgent extends BaseAgent { TestAgent() { super("test_agent", "test", ImmutableList.of(), null, null);