diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java index e53b0dc9b..8a889cbba 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java @@ -3,8 +3,8 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -292,7 +292,7 @@ private static io.a2a.grpc.TaskStatus taskStatus(TaskStatus taskStatus) { builder.setUpdate(message(taskStatus.message())); } if (taskStatus.timestamp() != null) { - Instant instant = taskStatus.timestamp().toInstant(ZoneOffset.UTC); + Instant instant = taskStatus.timestamp().toInstant(); builder.setTimestamp(com.google.protobuf.Timestamp.newBuilder().setSeconds(instant.getEpochSecond()).setNanos(instant.getNano()).build()); } return builder.build(); @@ -889,10 +889,13 @@ private static DataPart dataPart(io.a2a.grpc.DataPart dataPart) { } private static TaskStatus taskStatus(io.a2a.grpc.TaskStatus taskStatus) { - return new TaskStatus( - taskState(taskStatus.getState()), + TaskState state = taskState(taskStatus.getState()); + if (state == null) { + return null; + } + return new TaskStatus(state, taskStatus.hasUpdate() ? message(taskStatus.getUpdate()) : null, - LocalDateTime.ofInstant(Instant.ofEpochSecond(taskStatus.getTimestamp().getSeconds(), taskStatus.getTimestamp().getNanos()), ZoneOffset.UTC) + OffsetDateTime.ofInstant(Instant.ofEpochSecond(taskStatus.getTimestamp().getSeconds(), taskStatus.getTimestamp().getNanos()), ZoneOffset.UTC) ); } diff --git a/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java b/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java index aca9caaad..67e8463ed 100644 --- a/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java +++ b/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java @@ -22,6 +22,8 @@ import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; + +import java.time.OffsetDateTime; import java.util.Collections; import java.util.List; import java.util.Map; @@ -269,4 +271,22 @@ public void convertSendMessageConfiguration() { assertEquals(1, result.getAcceptedOutputModesCount()); assertEquals("text", result.getAcceptedOutputModesBytes(0).toStringUtf8()); } + + @Test + public void convertTaskTimestampStatus() { + OffsetDateTime expectedTimestamp = OffsetDateTime.parse("2024-10-05T12:34:56Z"); + TaskStatus testStatus = new TaskStatus(TaskState.COMPLETED, null, expectedTimestamp); + Task task = new Task.Builder() + .id("task-123") + .contextId("context-456") + .status(testStatus) + .build(); + + io.a2a.grpc.Task grpcTask = ProtoUtils.ToProto.task(task); + task = ProtoUtils.FromProto.task(grpcTask); + TaskStatus status = task.getStatus(); + assertEquals(TaskState.COMPLETED, status.state()); + assertNotNull(status.timestamp()); + assertEquals(expectedTimestamp, status.timestamp()); + } } diff --git a/spec/src/main/java/io/a2a/spec/TaskStatus.java b/spec/src/main/java/io/a2a/spec/TaskStatus.java index d41455d04..0b9bed654 100644 --- a/spec/src/main/java/io/a2a/spec/TaskStatus.java +++ b/spec/src/main/java/io/a2a/spec/TaskStatus.java @@ -1,6 +1,7 @@ package io.a2a.spec; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -14,14 +15,23 @@ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record TaskStatus(TaskState state, Message message, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS") LocalDateTime timestamp) { + @JsonFormat(shape = JsonFormat.Shape.STRING) OffsetDateTime timestamp) { public TaskStatus { Assert.checkNotNullParam("state", state); - timestamp = timestamp == null ? LocalDateTime.now() : timestamp; + timestamp = timestamp == null ? OffsetDateTime.now(ZoneOffset.UTC) : timestamp; } public TaskStatus(TaskState state) { this(state, null, null); } + + /** + * Constructor for testing purposes. + * @param state the task state + * @param timestamp timestamp generation + */ + TaskStatus(TaskState state, OffsetDateTime timestamp) { + this(state, null, timestamp); + } } diff --git a/spec/src/test/java/io/a2a/spec/TaskStatusTest.java b/spec/src/test/java/io/a2a/spec/TaskStatusTest.java new file mode 100644 index 000000000..7c4a9db8a --- /dev/null +++ b/spec/src/test/java/io/a2a/spec/TaskStatusTest.java @@ -0,0 +1,94 @@ +package io.a2a.spec; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TaskStatusTest { + + private static final ObjectMapper OBJECT_MAPPER; + + private static final String REPLACE_TIMESTAMP_PATTERN = ".*\"timestamp\":\"([^\"]+)\",?.*"; + + static { + OBJECT_MAPPER = new ObjectMapper(); + OBJECT_MAPPER.registerModule(new JavaTimeModule()); + OBJECT_MAPPER.configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, false); + } + + @Test + public void testTaskStatusWithSetTimestamp() { + TaskState state = TaskState.WORKING; + OffsetDateTime offsetDateTime = OffsetDateTime.parse("2023-10-01T12:00:00Z"); + TaskStatus status = new TaskStatus(state, offsetDateTime); + + assertNotNull(status.timestamp()); + assertEquals(offsetDateTime, status.timestamp()); + } + + @Test + public void testTaskStatusWithProvidedTimestamp() { + OffsetDateTime providedTimestamp = OffsetDateTime.parse("2024-01-01T00:00:00Z"); + TaskState state = TaskState.COMPLETED; + TaskStatus status = new TaskStatus(state, providedTimestamp); + + assertEquals(providedTimestamp, status.timestamp()); + } + + @Test + public void testTaskStatusSerializationUsesISO8601Format() throws Exception { + OffsetDateTime expectedTimestamp = OffsetDateTime.parse("2023-10-01T12:00:00.234-05:00"); + TaskState state = TaskState.WORKING; + TaskStatus status = new TaskStatus(state, expectedTimestamp); + + String json = OBJECT_MAPPER.writeValueAsString(status); + + String expectedJson = "{\"state\":\"working\",\"timestamp\":\"2023-10-01T12:00:00.234-05:00\"}"; + assertEquals(expectedJson, json); + } + + @Test + public void testTaskStatusDeserializationWithValidISO8601Format() throws Exception { + String validJson = "{" + + "\"state\": \"auth-required\"," + + "\"timestamp\": \"2023-10-01T12:00:00.10+03:00\"" + + "}"; + + TaskStatus result = OBJECT_MAPPER.readValue(validJson, TaskStatus.class); + assertEquals(TaskState.AUTH_REQUIRED, result.state()); + assertNotNull(result.timestamp()); + assertEquals(OffsetDateTime.parse("2023-10-01T12:00:00.100+03:00"), result.timestamp()); + } + + @Test + public void testTaskStatusDeserializationWithInvalidISO8601FormatFails() { + String invalidJson = "{" + + "\"state\": \"completed\"," + + "\"timestamp\": \"2023/10/01 12:00:00\"" + + "}"; + + assertThrows( + com.fasterxml.jackson.databind.exc.InvalidFormatException.class, + () -> OBJECT_MAPPER.readValue(invalidJson, TaskStatus.class) + ); + } + + @Test + public void testTaskStatusJsonTimestampMatchesISO8601Regex() throws Exception { + TaskState state = TaskState.WORKING; + OffsetDateTime expectedTimestamp = OffsetDateTime.parse("2023-10-01T12:00:00.234Z"); + TaskStatus status = new TaskStatus(state, expectedTimestamp); + + String json = OBJECT_MAPPER.writeValueAsString(status); + + String timestampValue = json.replaceAll(REPLACE_TIMESTAMP_PATTERN, "$1"); + assertEquals(expectedTimestamp, OffsetDateTime.parse(timestampValue)); + } +}