From 9ce78d7c3e1b0fb6d8d4fdce9052a572ffb9e515 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 12 Mar 2026 07:59:06 -0700 Subject: [PATCH] feat: Update converters for task and artifact events; add long running tools ids PiperOrigin-RevId: 882591822 --- .../adk/a2a/converters/PartConverter.java | 113 +++++++--------- .../adk/a2a/converters/ResponseConverter.java | 123 ++++++++++++++---- .../adk/a2a/converters/PartConverterTest.java | 77 +++++------ .../a2a/converters/ResponseConverterTest.java | 77 +++++++++++ 4 files changed, 249 insertions(+), 141 deletions(-) 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 36af6cc8b..61f24fa21 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 @@ -78,13 +78,13 @@ public static Optional toTextPart(io.a2a.spec.Part part) { } /** Convert an A2A JSON part into a Google GenAI part representation. */ - public static Optional toGenaiPart(io.a2a.spec.Part a2aPart) { + public static com.google.genai.types.Part toGenaiPart(io.a2a.spec.Part a2aPart) { if (a2aPart == null) { - return Optional.empty(); + throw new IllegalArgumentException("A2A part cannot be null"); } if (a2aPart instanceof TextPart textPart) { - return Optional.of(com.google.genai.types.Part.builder().text(textPart.getText()).build()); + return com.google.genai.types.Part.builder().text(textPart.getText()).build(); } if (a2aPart instanceof FilePart filePart) { @@ -95,56 +95,41 @@ public static Optional toGenaiPart(io.a2a.spec.Part return convertDataPartToGenAiPart(dataPart); } - logger.warn("Unsupported A2A part type: {}", a2aPart.getClass()); - return Optional.empty(); + throw new IllegalArgumentException("Unsupported A2A part type: " + a2aPart.getClass()); } public static ImmutableList toGenaiParts( List> a2aParts) { - return a2aParts.stream() - .map(PartConverter::toGenaiPart) - .flatMap(Optional::stream) - .collect(toImmutableList()); + return a2aParts.stream().map(PartConverter::toGenaiPart).collect(toImmutableList()); } - private static Optional convertFilePartToGenAiPart( - FilePart filePart) { + private static com.google.genai.types.Part convertFilePartToGenAiPart(FilePart filePart) { FileContent fileContent = filePart.getFile(); if (fileContent instanceof FileWithUri fileWithUri) { - return Optional.of( - com.google.genai.types.Part.builder() - .fileData( - FileData.builder() - .fileUri(fileWithUri.uri()) - .mimeType(fileWithUri.mimeType()) - .build()) - .build()); + return com.google.genai.types.Part.builder() + .fileData( + FileData.builder() + .fileUri(fileWithUri.uri()) + .mimeType(fileWithUri.mimeType()) + .build()) + .build(); } if (fileContent instanceof FileWithBytes fileWithBytes) { String bytesString = fileWithBytes.bytes(); if (bytesString == null) { - logger.warn("FileWithBytes missing byte content"); - return Optional.empty(); - } - try { - byte[] decoded = Base64.getDecoder().decode(bytesString); - return Optional.of( - com.google.genai.types.Part.builder() - .inlineData(Blob.builder().data(decoded).mimeType(fileWithBytes.mimeType()).build()) - .build()); - } catch (IllegalArgumentException e) { - logger.warn("Failed to decode base64 file content", e); - return Optional.empty(); + throw new GenAiFieldMissingException("FileWithBytes missing byte content"); } + byte[] decoded = Base64.getDecoder().decode(bytesString); + return com.google.genai.types.Part.builder() + .inlineData(Blob.builder().data(decoded).mimeType(fileWithBytes.mimeType()).build()) + .build(); } - logger.warn("Unsupported FilePart content: {}", fileContent.getClass()); - return Optional.empty(); + throw new IllegalArgumentException("Unsupported FilePart content: " + fileContent.getClass()); } - private static Optional convertDataPartToGenAiPart( - DataPart dataPart) { + private static com.google.genai.types.Part convertDataPartToGenAiPart(DataPart dataPart) { Map data = Optional.ofNullable(dataPart.getData()).map(HashMap::new).orElseGet(HashMap::new); Map metadata = @@ -154,14 +139,12 @@ private static Optional convertDataPartToGenAiPart( if ((data.containsKey(NAME_KEY) && data.containsKey(ARGS_KEY)) || metadataType.equals(A2ADataPartMetadataType.FUNCTION_CALL.getType())) { - String functionName = String.valueOf(data.getOrDefault(NAME_KEY, null)); - String functionId = String.valueOf(data.getOrDefault(ID_KEY, null)); + String functionName = String.valueOf(data.getOrDefault(NAME_KEY, "")); + String functionId = String.valueOf(data.getOrDefault(ID_KEY, "")); Map args = coerceToMap(data.get(ARGS_KEY)); - return Optional.of( - com.google.genai.types.Part.builder() - .functionCall( - FunctionCall.builder().name(functionName).id(functionId).args(args).build()) - .build()); + return com.google.genai.types.Part.builder() + .functionCall(FunctionCall.builder().name(functionName).id(functionId).args(args).build()) + .build(); } if ((data.containsKey(NAME_KEY) && data.containsKey(RESPONSE_KEY)) @@ -169,15 +152,14 @@ private static Optional convertDataPartToGenAiPart( String functionName = String.valueOf(data.getOrDefault(NAME_KEY, "")); String functionId = String.valueOf(data.getOrDefault(ID_KEY, "")); Map response = coerceToMap(data.get(RESPONSE_KEY)); - return Optional.of( - com.google.genai.types.Part.builder() - .functionResponse( - FunctionResponse.builder() - .name(functionName) - .id(functionId) - .response(response) - .build()) - .build()); + return com.google.genai.types.Part.builder() + .functionResponse( + FunctionResponse.builder() + .name(functionName) + .id(functionId) + .response(response) + .build()) + .build(); } if ((data.containsKey(CODE_KEY) && data.containsKey(LANGUAGE_KEY)) @@ -185,13 +167,11 @@ private static Optional convertDataPartToGenAiPart( String code = String.valueOf(data.getOrDefault(CODE_KEY, "")); String language = String.valueOf( - data.getOrDefault(LANGUAGE_KEY, Language.Known.LANGUAGE_UNSPECIFIED.toString()) - .toString()); - return Optional.of( - com.google.genai.types.Part.builder() - .executableCode( - ExecutableCode.builder().code(code).language(new Language(language)).build()) - .build()); + data.getOrDefault(LANGUAGE_KEY, Language.Known.LANGUAGE_UNSPECIFIED.toString())); + return com.google.genai.types.Part.builder() + .executableCode( + ExecutableCode.builder().code(code).language(new Language(language)).build()) + .build(); } if ((data.containsKey(OUTCOME_KEY) && data.containsKey(OUTPUT_KEY)) @@ -199,22 +179,17 @@ private static Optional convertDataPartToGenAiPart( String outcome = String.valueOf(data.getOrDefault(OUTCOME_KEY, Outcome.Known.OUTCOME_OK).toString()); String output = String.valueOf(data.getOrDefault(OUTPUT_KEY, "")); - return Optional.of( - com.google.genai.types.Part.builder() - .codeExecutionResult( - CodeExecutionResult.builder() - .outcome(new Outcome(outcome)) - .output(output) - .build()) - .build()); + return com.google.genai.types.Part.builder() + .codeExecutionResult( + CodeExecutionResult.builder().outcome(new Outcome(outcome)).output(output).build()) + .build(); } try { String json = objectMapper.writeValueAsString(data); - return Optional.of(com.google.genai.types.Part.builder().text(json).build()); + return com.google.genai.types.Part.builder().text(json).build(); } catch (JsonProcessingException e) { - logger.warn("Failed to serialize DataPart payload", e); - return Optional.empty(); + throw new IllegalArgumentException("Failed to serialize DataPart payload", e); } } 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 f3be48c1b..503432a30 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 @@ -16,6 +16,8 @@ package com.google.adk.a2a.converters; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.common.collect.Streams.zip; import com.google.adk.agents.InvocationContext; import com.google.adk.events.Event; @@ -29,6 +31,7 @@ import io.a2a.client.TaskEvent; import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.Artifact; +import io.a2a.spec.DataPart; import io.a2a.spec.Message; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; @@ -36,6 +39,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -70,6 +74,14 @@ public static Optional clientEventToEvent( throw new IllegalArgumentException("Unsupported ClientEvent type: " + event.getClass()); } + private static boolean isPartial(Map metadata) { + if (metadata == null) { + return false; + } + return Objects.equals( + metadata.getOrDefault(PartConverter.A2A_DATA_PART_METADATA_IS_PARTIAL_KEY, false), 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 @@ -85,7 +97,14 @@ private static Optional handleTaskUpdate( boolean isAppend = Objects.equals(artifactEvent.isAppend(), true); boolean isLastChunk = Objects.equals(artifactEvent.isLastChunk(), true); + if (isLastChunk && isPartial(artifactEvent.getMetadata())) { + return Optional.empty(); + } + Event eventPart = artifactToEvent(artifactEvent.getArtifact(), context); + if (eventPart.content().flatMap(Content::parts).orElse(ImmutableList.of()).isEmpty()) { + return Optional.empty(); + } eventPart.setPartial(isAppend || !isLastChunk); // append=true, lastChunk=false: emit as partial, update aggregation // append=false, lastChunk=false: emit as partial, reset aggregation @@ -115,9 +134,8 @@ private static Optional handleTaskUpdate( .map(builder -> builder.turnComplete(true)) .map(builder -> builder.partial(false)) .map(Event.Builder::build); - } else { - return messageEvent; } + return messageEvent; } throw new IllegalArgumentException( "Unsupported TaskUpdateEvent type: " + updateEvent.getClass()); @@ -125,16 +143,12 @@ private static Optional handleTaskUpdate( /** Converts an artifact to an ADK event. */ public static Event artifactToEvent(Artifact artifact, InvocationContext invocationContext) { - Message message = - new Message.Builder().role(Message.Role.AGENT).parts(artifact.parts()).build(); - return messageToEvent(message, invocationContext); - } - - /** 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(); + Event.Builder eventBuilder = remoteAgentEventBuilder(invocationContext); + ImmutableList genaiParts = PartConverter.toGenaiParts(artifact.parts()); + eventBuilder + .content(fromModelParts(genaiParts)) + .longRunningToolIds(getLongRunningToolIds(artifact.parts(), genaiParts)); + return eventBuilder.build(); } /** Converts an A2A message for a failed task to ADK event filling in the error message. */ @@ -147,6 +161,13 @@ public static Event messageToFailedEvent(Message message, InvocationContext invo return builder.build(); } + /** 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(); + } + /** * Converts an A2A message back to ADK events. For streaming task in pending state it sets the * thought field to true, to mark them as thought updates. @@ -168,25 +189,71 @@ public static Event messageToEvent( * If none of these are present, an empty event is returned. */ public static Event taskToEvent(Task task, InvocationContext invocationContext) { - Message taskMessage = null; - - if (!task.getArtifacts().isEmpty()) { - taskMessage = - new Message.Builder() - .messageId("") - .role(Message.Role.AGENT) - .parts(Iterables.getLast(task.getArtifacts()).parts()) - .build(); - } else if (task.getStatus().message() != null) { - taskMessage = task.getStatus().message(); - } else if (!task.getHistory().isEmpty()) { - taskMessage = Iterables.getLast(task.getHistory()); + ImmutableList.Builder genaiParts = ImmutableList.builder(); + ImmutableSet.Builder longRunningToolIds = ImmutableSet.builder(); + + for (Artifact artifact : task.getArtifacts()) { + ImmutableList converted = PartConverter.toGenaiParts(artifact.parts()); + longRunningToolIds.addAll(getLongRunningToolIds(artifact.parts(), converted)); + genaiParts.addAll(converted); + } + + Event.Builder eventBuilder = remoteAgentEventBuilder(invocationContext); + + if (task.getStatus().message() != null) { + ImmutableList msgParts = + PartConverter.toGenaiParts(task.getStatus().message().getParts()); + longRunningToolIds.addAll( + getLongRunningToolIds(task.getStatus().message().getParts(), msgParts)); + if (task.getStatus().state() == TaskState.FAILED + && msgParts.size() == 1 + && msgParts.get(0).text().isPresent()) { + eventBuilder.errorMessage(msgParts.get(0).text().get()); + } else { + genaiParts.addAll(msgParts); + } } - if (taskMessage != null) { - return messageToEvent(taskMessage, invocationContext); + ImmutableList finalParts = genaiParts.build(); + boolean isFinal = + task.getStatus().state().isFinal() || task.getStatus().state() == TaskState.INPUT_REQUIRED; + + if (finalParts.isEmpty() && !isFinal) { + return emptyEvent(invocationContext); } - return emptyEvent(invocationContext); + if (!finalParts.isEmpty()) { + eventBuilder.content(fromModelParts(finalParts)); + } + if (task.getStatus().state() == TaskState.INPUT_REQUIRED) { + eventBuilder.longRunningToolIds(longRunningToolIds.build()); + } + eventBuilder.turnComplete(isFinal); + return eventBuilder.build(); + } + + private static ImmutableSet getLongRunningToolIds( + List> parts, List convertedParts) { + return zip( + parts.stream(), + convertedParts.stream(), + (part, convertedPart) -> { + if (!(part instanceof DataPart dataPart)) { + return Optional.empty(); + } + Object isLongRunning = + dataPart + .getMetadata() + .get(PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY); + if (!Objects.equals(isLongRunning, true)) { + return Optional.empty(); + } + if (convertedPart.functionCall().isEmpty()) { + return Optional.empty(); + } + return convertedPart.functionCall().get().id(); + }) + .flatMap(Optional::stream) + .collect(toImmutableSet()); } private static Event emptyEvent(InvocationContext invocationContext) { 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 8e8982ffa..d93466dd2 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 @@ -18,7 +18,6 @@ import io.a2a.spec.FileWithUri; import io.a2a.spec.TextPart; import java.util.Base64; -import java.util.Optional; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -27,29 +26,27 @@ public class PartConverterTest { @Test - public void toGenaiPart_withNullPart_returnsEmpty() { - assertThat(PartConverter.toGenaiPart(null)).isEmpty(); + public void toGenaiPart_withNullPart_throwsException() { + assertThrows(IllegalArgumentException.class, () -> PartConverter.toGenaiPart(null)); } @Test public void toGenaiPart_withTextPart_returnsGenaiTextPart() { TextPart textPart = new TextPart("Hello"); - Optional result = PartConverter.toGenaiPart(textPart); + Part result = PartConverter.toGenaiPart(textPart); - assertThat(result).isPresent(); - assertThat(result.get().text()).hasValue("Hello"); + assertThat(result.text()).hasValue("Hello"); } @Test public void toGenaiPart_withFilePartUri_returnsGenaiFilePart() { FilePart filePart = new FilePart(new FileWithUri("text/plain", "file.txt", "http://file.txt")); - Optional result = PartConverter.toGenaiPart(filePart); + Part result = PartConverter.toGenaiPart(filePart); - assertThat(result).isPresent(); - assertThat(result.get().fileData()).isPresent(); - FileData fileData = result.get().fileData().get(); + assertThat(result.fileData()).isPresent(); + FileData fileData = result.fileData().get(); assertThat(fileData.mimeType()).hasValue("text/plain"); assertThat(fileData.fileUri()).hasValue("http://file.txt"); } @@ -60,26 +57,25 @@ public void toGenaiPart_withFilePartBytes_returnsGenaiBlobPart() { String encoded = Base64.getEncoder().encodeToString(bytes); FilePart filePart = new FilePart(new FileWithBytes("text/plain", "file.txt", encoded)); - Optional result = PartConverter.toGenaiPart(filePart); + Part result = PartConverter.toGenaiPart(filePart); - assertThat(result).isPresent(); - assertThat(result.get().inlineData()).isPresent(); - Blob blob = result.get().inlineData().get(); + assertThat(result.inlineData()).isPresent(); + Blob blob = result.inlineData().get(); assertThat(blob.mimeType()).hasValue("text/plain"); assertThat(blob.data().get()).isEqualTo(bytes); } @Test - public void toGenaiPart_withFilePartBytes_handlesNullBytes() { + public void toGenaiPart_withFilePartBytes_handlesNullBytes_throwsException() { FilePart filePart = new FilePart(new FileWithBytes("text/plain", "file.txt", null)); - assertThat(PartConverter.toGenaiPart(filePart)).isEmpty(); + assertThrows(GenAiFieldMissingException.class, () -> PartConverter.toGenaiPart(filePart)); } @Test public void toGenaiPart_withFilePartBytes_handlesInvalidBase64() { FilePart filePart = new FilePart(new FileWithBytes("text/plain", "file.txt", "invalid-base64!")); - assertThat(PartConverter.toGenaiPart(filePart)).isEmpty(); + assertThrows(IllegalArgumentException.class, () -> PartConverter.toGenaiPart(filePart)); } @Test @@ -93,11 +89,10 @@ public void toGenaiPart_withDataPartFunctionCall_returnsGenaiFunctionCallPart() PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_CALL.getType())); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionCall()).isPresent(); - FunctionCall functionCall = result.get().functionCall().get(); + assertThat(result.functionCall()).isPresent(); + FunctionCall functionCall = result.functionCall().get(); assertThat(functionCall.name()).hasValue("func"); assertThat(functionCall.id()).hasValue("1"); assertThat(functionCall.args()).hasValue(ImmutableMap.of()); @@ -109,11 +104,10 @@ public void toGenaiPart_withDataPartFunctionCallByNameAndArgs_returnsGenaiFuncti ImmutableMap.of("name", "func", "id", "1", "args", ImmutableMap.of("param", "value")); DataPart dataPart = new DataPart(data, null); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionCall()).isPresent(); - FunctionCall functionCall = result.get().functionCall().get(); + assertThat(result.functionCall()).isPresent(); + FunctionCall functionCall = result.functionCall().get(); assertThat(functionCall.name()).hasValue("func"); assertThat(functionCall.id()).hasValue("1"); assertThat(functionCall.args()).hasValue(ImmutableMap.of("param", "value")); @@ -130,11 +124,10 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, A2ADataPartMetadataType.FUNCTION_RESPONSE.getType())); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionResponse()).isPresent(); - FunctionResponse functionResponse = result.get().functionResponse().get(); + assertThat(result.functionResponse()).isPresent(); + FunctionResponse functionResponse = result.functionResponse().get(); assertThat(functionResponse.name()).hasValue("func"); assertThat(functionResponse.id()).hasValue("1"); assertThat(functionResponse.response()).hasValue(ImmutableMap.of()); @@ -147,11 +140,10 @@ public void toGenaiPart_withDataPartFunctionResponse_returnsGenaiFunctionRespons ImmutableMap.of("name", "func", "id", "1", "response", ImmutableMap.of("result", "value")); DataPart dataPart = new DataPart(data, null); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionResponse()).isPresent(); - FunctionResponse functionResponse = result.get().functionResponse().get(); + assertThat(result.functionResponse()).isPresent(); + FunctionResponse functionResponse = result.functionResponse().get(); assertThat(functionResponse.name()).hasValue("func"); assertThat(functionResponse.id()).hasValue("1"); assertThat(functionResponse.response()).hasValue(ImmutableMap.of("result", "value")); @@ -162,10 +154,9 @@ public void toGenaiPart_withOtherDataPart_returnsGenaiTextPartWithJson() { ImmutableMap data = ImmutableMap.of("key", "value"); DataPart dataPart = new DataPart(data, null); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().text()).hasValue("{\"key\":\"value\"}"); + assertThat(result.text()).hasValue("{\"key\":\"value\"}"); } @Test @@ -293,11 +284,10 @@ public void toGenaiPart_dataPartWithEmptyStringCoercedToEmptyMap() { ImmutableMap data = ImmutableMap.of("name", "func", "id", "1", "args", ""); DataPart dataPart = new DataPart(data, null); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionCall()).isPresent(); - assertThat(result.get().functionCall().get().args()).hasValue(ImmutableMap.of()); + assertThat(result.functionCall()).isPresent(); + assertThat(result.functionCall().get().args()).hasValue(ImmutableMap.of()); } @Test @@ -305,10 +295,9 @@ public void toGenaiPart_dataPartWithNonMapCoercedToMap() { ImmutableMap data = ImmutableMap.of("name", "func", "id", "1", "args", 123); DataPart dataPart = new DataPart(data, null); - Optional result = PartConverter.toGenaiPart(dataPart); + Part result = PartConverter.toGenaiPart(dataPart); - assertThat(result).isPresent(); - assertThat(result.get().functionCall()).isPresent(); - assertThat(result.get().functionCall().get().args()).hasValue(ImmutableMap.of("value", 123)); + assertThat(result.functionCall()).isPresent(); + assertThat(result.functionCall().get().args()).hasValue(ImmutableMap.of("value", 123)); } } 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 5378bdd7b..d84dc42cd 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 @@ -11,10 +11,12 @@ import com.google.adk.sessions.InMemorySessionService; import com.google.adk.sessions.Session; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.genai.types.Content; import io.a2a.client.MessageEvent; import io.a2a.client.TaskUpdateEvent; import io.a2a.spec.Artifact; +import io.a2a.spec.DataPart; import io.a2a.spec.Message; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; @@ -144,6 +146,81 @@ public void taskToEvent_withNoMessage_returnsEmptyEvent() { assertThat(event.invocationId()).isEqualTo(invocationContext.invocationId()); } + @Test + public void taskToEvent_withInputRequired_parsesLongRunningToolIds() { + ImmutableMap data = + ImmutableMap.of("name", "myTool", "id", "call_123", "args", ImmutableMap.of()); + ImmutableMap metadata = + ImmutableMap.of( + PartConverter.A2A_DATA_PART_METADATA_TYPE_KEY, + "function_call", + PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, + 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, + "function_call", + PartConverter.A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, + true); + DataPart statusDataPart = new DataPart(statusData, statusMetadata); + 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).isNotNull(); + assertThat(event.longRunningToolIds().get()).containsExactly("call_123", "msg_123"); + } + + @Test + public void taskToEvent_withFailedState_setsErrorCode() { + Message statusMessage = + new Message.Builder() + .role(Message.Role.AGENT) + .parts(ImmutableList.of(new TextPart("Task failed"))) + .build(); + TaskStatus status = new TaskStatus(TaskState.FAILED, statusMessage, null); + Task task = testTask().status(status).artifacts(ImmutableList.of()).build(); + + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.errorMessage()).hasValue("Task failed"); + } + + @Test + public void taskToEvent_withFinalEvent_returnsEmptyEvent() { + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Task task = testTask().status(status).artifacts(ImmutableList.of()).build(); + + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.invocationId()).isEqualTo(invocationContext.invocationId()); + assertThat(event.turnComplete()).hasValue(true); + assertThat(event.content().flatMap(Content::parts).orElse(ImmutableList.of())).isEmpty(); + } + + @Test + public void taskToEvent_withEmptyParts_returnsEmptyEvent() { + TaskStatus status = new TaskStatus(TaskState.SUBMITTED); + Task task = testTask().status(status).artifacts(ImmutableList.of()).build(); + + Event event = ResponseConverter.taskToEvent(task, invocationContext); + assertThat(event).isNotNull(); + assertThat(event.invocationId()).isEqualTo(invocationContext.invocationId()); + assertThat(event.content()).isPresent(); + assertThat(event.content().get().parts().orElse(ImmutableList.of())).isEmpty(); + } + @Test public void clientEventToEvent_withTaskUpdateEventAndThought_returnsThoughtEvent() { Message statusMessage =