Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can taskStatus.getState() return null? I don't seem to see that here:

https://github.com/a2aproject/a2a-java/blob/main/spec-grpc/src/main/java/io/a2a/grpc/TaskStatus.java#L75

Let me know if I'm missing something.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ehsavoie Just bumping this question again in case you missed it.

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)
);
}

Expand Down
20 changes: 20 additions & 0 deletions spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
16 changes: 13 additions & 3 deletions spec/src/main/java/io/a2a/spec/TaskStatus.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
94 changes: 94 additions & 0 deletions spec/src/test/java/io/a2a/spec/TaskStatusTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}