Skip to content

Commit 3342383

Browse files
ehsavoieclaude
andcommitted
feat: Add bidirectional mapper for StreamResponse and StreamingEventKind
Created StreamResponseMapper to handle conversion between: - Proto: io.a2a.grpc.StreamResponse (protobuf oneof field) - Domain: io.a2a.spec.StreamingEventKind (sealed interface) The mapper supports all four streaming event types: - Task (complete task state) - Message (full message) - TaskStatusUpdateEvent (status update events) - TaskArtifactUpdateEvent (artifact update events) Implementation details: - toProto(): Uses instanceof pattern matching to determine which oneof field to set - fromProto(): Uses switch expression on PayloadCase enum - Delegates to existing mappers: TaskMapper, MessageMapper, TaskStatusUpdateEventMapper, TaskArtifactUpdateEventMapper - Throws IllegalArgumentException when payload is not set Added comprehensive test coverage: - Bidirectional conversion for all four event types - Error handling for unset payload - Roundtrip tests to verify data preservation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f9cd4c4 commit 3342383

File tree

2 files changed

+364
-0
lines changed

2 files changed

+364
-0
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.a2a.grpc.mapper;
2+
3+
import io.a2a.spec.Message;
4+
import io.a2a.spec.StreamingEventKind;
5+
import io.a2a.spec.Task;
6+
import io.a2a.spec.TaskArtifactUpdateEvent;
7+
import io.a2a.spec.TaskStatusUpdateEvent;
8+
import org.mapstruct.Mapper;
9+
10+
/**
11+
* Mapper between {@link io.a2a.spec.StreamingEventKind} and {@link io.a2a.grpc.StreamResponse}.
12+
* <p>
13+
* StreamResponse uses a protobuf oneof field to represent polymorphic streaming events.
14+
* StreamingEventKind is a sealed interface with four permitted implementations:
15+
* <ul>
16+
* <li>{@link Task} - Complete task state</li>
17+
* <li>{@link Message} - Full message</li>
18+
* <li>{@link TaskStatusUpdateEvent} - Status update event</li>
19+
* <li>{@link TaskArtifactUpdateEvent} - Artifact update event</li>
20+
* </ul>
21+
* <p>
22+
* This mapper provides bidirectional conversion using instanceof checks (toProto)
23+
* and switch expressions on the oneof case (fromProto).
24+
*/
25+
@Mapper(config = A2AProtoMapperConfig.class,
26+
uses = {TaskMapper.class, MessageMapper.class, TaskStatusUpdateEventMapper.class, TaskArtifactUpdateEventMapper.class})
27+
public interface StreamResponseMapper {
28+
29+
StreamResponseMapper INSTANCE = A2AMappers.getMapper(StreamResponseMapper.class);
30+
31+
/**
32+
* Converts domain StreamingEventKind to proto StreamResponse.
33+
* Uses instanceof checks to determine which oneof field to set.
34+
*
35+
* @param domain the streaming event kind (Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent)
36+
* @return the proto StreamResponse with the appropriate oneof field set
37+
*/
38+
default io.a2a.grpc.StreamResponse toProto(StreamingEventKind domain) {
39+
if (domain == null) {
40+
return null;
41+
}
42+
43+
if (domain instanceof Task task) {
44+
return io.a2a.grpc.StreamResponse.newBuilder()
45+
.setTask(TaskMapper.INSTANCE.toProto(task))
46+
.build();
47+
} else if (domain instanceof Message message) {
48+
return io.a2a.grpc.StreamResponse.newBuilder()
49+
.setMsg(MessageMapper.INSTANCE.toProto(message))
50+
.build();
51+
} else if (domain instanceof TaskStatusUpdateEvent statusUpdate) {
52+
return io.a2a.grpc.StreamResponse.newBuilder()
53+
.setStatusUpdate(TaskStatusUpdateEventMapper.INSTANCE.toProto(statusUpdate))
54+
.build();
55+
} else if (domain instanceof TaskArtifactUpdateEvent artifactUpdate) {
56+
return io.a2a.grpc.StreamResponse.newBuilder()
57+
.setArtifactUpdate(TaskArtifactUpdateEventMapper.INSTANCE.toProto(artifactUpdate))
58+
.build();
59+
}
60+
61+
throw new IllegalArgumentException("Unknown StreamingEventKind type: " + domain.getClass().getName());
62+
}
63+
64+
/**
65+
* Converts proto StreamResponse to domain StreamingEventKind.
66+
* Uses switch expression on the oneof case to determine which type to return.
67+
*
68+
* @param proto the proto StreamResponse
69+
* @return the corresponding domain streaming event kind
70+
* @throws IllegalArgumentException if the oneof field is not set
71+
*/
72+
default StreamingEventKind fromProto(io.a2a.grpc.StreamResponse proto) {
73+
if (proto == null) {
74+
return null;
75+
}
76+
77+
return switch (proto.getPayloadCase()) {
78+
case TASK ->
79+
TaskMapper.INSTANCE.fromProto(proto.getTask());
80+
case MSG ->
81+
MessageMapper.INSTANCE.fromProto(proto.getMsg());
82+
case STATUS_UPDATE ->
83+
TaskStatusUpdateEventMapper.INSTANCE.fromProto(proto.getStatusUpdate());
84+
case ARTIFACT_UPDATE ->
85+
TaskArtifactUpdateEventMapper.INSTANCE.fromProto(proto.getArtifactUpdate());
86+
case PAYLOAD_NOT_SET ->
87+
throw new IllegalArgumentException("StreamResponse payload oneof field not set");
88+
};
89+
}
90+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
package io.a2a.grpc.mapper;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
5+
import static org.junit.jupiter.api.Assertions.assertNotNull;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
import io.a2a.spec.Artifact;
9+
import io.a2a.spec.Message;
10+
import io.a2a.spec.StreamingEventKind;
11+
import io.a2a.spec.Task;
12+
import io.a2a.spec.TaskArtifactUpdateEvent;
13+
import io.a2a.spec.TaskState;
14+
import io.a2a.spec.TaskStatus;
15+
import io.a2a.spec.TaskStatusUpdateEvent;
16+
import io.a2a.spec.TextPart;
17+
import java.util.Collections;
18+
import org.junit.jupiter.api.Test;
19+
20+
public class StreamResponseMapperTest {
21+
22+
@Test
23+
void testConvertTask_ToProto() {
24+
// Arrange
25+
Task task = new Task.Builder()
26+
.id("task-123")
27+
.contextId("context-456")
28+
.status(new TaskStatus(TaskState.COMPLETED))
29+
.build();
30+
31+
// Act
32+
io.a2a.grpc.StreamResponse result = StreamResponseMapper.INSTANCE.toProto(task);
33+
34+
// Assert
35+
assertNotNull(result);
36+
assertEquals(io.a2a.grpc.StreamResponse.PayloadCase.TASK, result.getPayloadCase());
37+
assertEquals("task-123", result.getTask().getId());
38+
assertEquals("context-456", result.getTask().getContextId());
39+
assertEquals(io.a2a.grpc.TaskState.TASK_STATE_COMPLETED, result.getTask().getStatus().getState());
40+
}
41+
42+
@Test
43+
void testConvertTask_FromProto() {
44+
// Arrange
45+
io.a2a.grpc.StreamResponse proto = io.a2a.grpc.StreamResponse.newBuilder()
46+
.setTask(io.a2a.grpc.Task.newBuilder()
47+
.setId("task-123")
48+
.setContextId("context-456")
49+
.setStatus(io.a2a.grpc.TaskStatus.newBuilder()
50+
.setState(io.a2a.grpc.TaskState.TASK_STATE_COMPLETED)
51+
.build())
52+
.build())
53+
.build();
54+
55+
// Act
56+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
57+
58+
// Assert
59+
assertNotNull(result);
60+
assertInstanceOf(Task.class, result);
61+
Task task = (Task) result;
62+
assertEquals("task-123", task.getId());
63+
assertEquals("context-456", task.getContextId());
64+
assertEquals(TaskState.COMPLETED, task.getStatus().state());
65+
}
66+
67+
@Test
68+
void testConvertMessage_ToProto() {
69+
// Arrange
70+
Message message = new Message.Builder()
71+
.messageId("msg-123")
72+
.contextId("context-456")
73+
.role(Message.Role.USER)
74+
.parts(Collections.singletonList(new TextPart("Hello")))
75+
.build();
76+
77+
// Act
78+
io.a2a.grpc.StreamResponse result = StreamResponseMapper.INSTANCE.toProto(message);
79+
80+
// Assert
81+
assertNotNull(result);
82+
assertEquals(io.a2a.grpc.StreamResponse.PayloadCase.MSG, result.getPayloadCase());
83+
assertEquals("msg-123", result.getMsg().getMessageId());
84+
assertEquals("context-456", result.getMsg().getContextId());
85+
assertEquals(io.a2a.grpc.Role.ROLE_USER, result.getMsg().getRole());
86+
}
87+
88+
@Test
89+
void testConvertMessage_FromProto() {
90+
// Arrange
91+
io.a2a.grpc.StreamResponse proto = io.a2a.grpc.StreamResponse.newBuilder()
92+
.setMsg(io.a2a.grpc.Message.newBuilder()
93+
.setMessageId("msg-123")
94+
.setContextId("context-456")
95+
.setRole(io.a2a.grpc.Role.ROLE_USER)
96+
.addParts(io.a2a.grpc.Part.newBuilder()
97+
.setText("Hello")
98+
.build())
99+
.build())
100+
.build();
101+
102+
// Act
103+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
104+
105+
// Assert
106+
assertNotNull(result);
107+
assertInstanceOf(Message.class, result);
108+
Message message = (Message) result;
109+
assertEquals("msg-123", message.getMessageId());
110+
assertEquals("context-456", message.getContextId());
111+
assertEquals(Message.Role.USER, message.getRole());
112+
}
113+
114+
@Test
115+
void testConvertTaskStatusUpdateEvent_ToProto() {
116+
// Arrange
117+
TaskStatusUpdateEvent event = new TaskStatusUpdateEvent.Builder()
118+
.taskId("task-123")
119+
.contextId("context-456")
120+
.status(new TaskStatus(TaskState.WORKING))
121+
.isFinal(false)
122+
.build();
123+
124+
// Act
125+
io.a2a.grpc.StreamResponse result = StreamResponseMapper.INSTANCE.toProto(event);
126+
127+
// Assert
128+
assertNotNull(result);
129+
assertEquals(io.a2a.grpc.StreamResponse.PayloadCase.STATUS_UPDATE, result.getPayloadCase());
130+
assertEquals("task-123", result.getStatusUpdate().getTaskId());
131+
assertEquals("context-456", result.getStatusUpdate().getContextId());
132+
assertEquals(io.a2a.grpc.TaskState.TASK_STATE_WORKING, result.getStatusUpdate().getStatus().getState());
133+
assertEquals(false, result.getStatusUpdate().getFinal());
134+
}
135+
136+
@Test
137+
void testConvertTaskStatusUpdateEvent_FromProto() {
138+
// Arrange
139+
io.a2a.grpc.StreamResponse proto = io.a2a.grpc.StreamResponse.newBuilder()
140+
.setStatusUpdate(io.a2a.grpc.TaskStatusUpdateEvent.newBuilder()
141+
.setTaskId("task-123")
142+
.setContextId("context-456")
143+
.setStatus(io.a2a.grpc.TaskStatus.newBuilder()
144+
.setState(io.a2a.grpc.TaskState.TASK_STATE_WORKING)
145+
.build())
146+
.setFinal(false)
147+
.build())
148+
.build();
149+
150+
// Act
151+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
152+
153+
// Assert
154+
assertNotNull(result);
155+
assertInstanceOf(TaskStatusUpdateEvent.class, result);
156+
TaskStatusUpdateEvent event = (TaskStatusUpdateEvent) result;
157+
assertEquals("task-123", event.getTaskId());
158+
assertEquals("context-456", event.getContextId());
159+
assertEquals(TaskState.WORKING, event.getStatus().state());
160+
assertEquals(false, event.isFinal());
161+
}
162+
163+
@Test
164+
void testConvertTaskArtifactUpdateEvent_ToProto() {
165+
// Arrange
166+
TaskArtifactUpdateEvent event = new TaskArtifactUpdateEvent.Builder()
167+
.taskId("task-123")
168+
.contextId("context-456")
169+
.artifact(new Artifact.Builder()
170+
.artifactId("artifact-1")
171+
.name("result")
172+
.parts(new TextPart("Result text"))
173+
.build())
174+
.build();
175+
176+
// Act
177+
io.a2a.grpc.StreamResponse result = StreamResponseMapper.INSTANCE.toProto(event);
178+
179+
// Assert
180+
assertNotNull(result);
181+
assertEquals(io.a2a.grpc.StreamResponse.PayloadCase.ARTIFACT_UPDATE, result.getPayloadCase());
182+
assertEquals("task-123", result.getArtifactUpdate().getTaskId());
183+
assertEquals("context-456", result.getArtifactUpdate().getContextId());
184+
assertEquals("artifact-1", result.getArtifactUpdate().getArtifact().getArtifactId());
185+
assertEquals("result", result.getArtifactUpdate().getArtifact().getName());
186+
}
187+
188+
@Test
189+
void testConvertTaskArtifactUpdateEvent_FromProto() {
190+
// Arrange
191+
io.a2a.grpc.StreamResponse proto = io.a2a.grpc.StreamResponse.newBuilder()
192+
.setArtifactUpdate(io.a2a.grpc.TaskArtifactUpdateEvent.newBuilder()
193+
.setTaskId("task-123")
194+
.setContextId("context-456")
195+
.setArtifact(io.a2a.grpc.Artifact.newBuilder()
196+
.setArtifactId("artifact-1")
197+
.setName("result")
198+
.addParts(io.a2a.grpc.Part.newBuilder()
199+
.setText("Result text")
200+
.build())
201+
.build())
202+
.build())
203+
.build();
204+
205+
// Act
206+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
207+
208+
// Assert
209+
assertNotNull(result);
210+
assertInstanceOf(TaskArtifactUpdateEvent.class, result);
211+
TaskArtifactUpdateEvent event = (TaskArtifactUpdateEvent) result;
212+
assertEquals("task-123", event.getTaskId());
213+
assertEquals("context-456", event.getContextId());
214+
assertEquals("artifact-1", event.getArtifact().artifactId());
215+
assertEquals("result", event.getArtifact().name());
216+
}
217+
218+
@Test
219+
void testConvertStreamResponse_FromProto_PayloadNotSet_ThrowsException() {
220+
// Arrange
221+
io.a2a.grpc.StreamResponse proto = io.a2a.grpc.StreamResponse.newBuilder().build();
222+
223+
// Act & Assert
224+
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
225+
StreamResponseMapper.INSTANCE.fromProto(proto);
226+
});
227+
assertEquals("StreamResponse payload oneof field not set", exception.getMessage());
228+
}
229+
230+
@Test
231+
void testConvertStreamResponse_Roundtrip_Task() {
232+
// Arrange
233+
Task originalTask = new Task.Builder()
234+
.id("task-123")
235+
.contextId("context-456")
236+
.status(new TaskStatus(TaskState.SUBMITTED))
237+
.build();
238+
239+
// Act
240+
io.a2a.grpc.StreamResponse proto = StreamResponseMapper.INSTANCE.toProto(originalTask);
241+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
242+
243+
// Assert
244+
assertNotNull(result);
245+
assertInstanceOf(Task.class, result);
246+
Task roundtrippedTask = (Task) result;
247+
assertEquals(originalTask.getId(), roundtrippedTask.getId());
248+
assertEquals(originalTask.getContextId(), roundtrippedTask.getContextId());
249+
assertEquals(originalTask.getStatus().state(), roundtrippedTask.getStatus().state());
250+
}
251+
252+
@Test
253+
void testConvertStreamResponse_Roundtrip_Message() {
254+
// Arrange
255+
Message originalMessage = new Message.Builder()
256+
.messageId("msg-123")
257+
.contextId("context-456")
258+
.role(Message.Role.AGENT)
259+
.parts(Collections.singletonList(new TextPart("Response")))
260+
.build();
261+
262+
// Act
263+
io.a2a.grpc.StreamResponse proto = StreamResponseMapper.INSTANCE.toProto(originalMessage);
264+
StreamingEventKind result = StreamResponseMapper.INSTANCE.fromProto(proto);
265+
266+
// Assert
267+
assertNotNull(result);
268+
assertInstanceOf(Message.class, result);
269+
Message roundtrippedMessage = (Message) result;
270+
assertEquals(originalMessage.getMessageId(), roundtrippedMessage.getMessageId());
271+
assertEquals(originalMessage.getContextId(), roundtrippedMessage.getContextId());
272+
assertEquals(originalMessage.getRole(), roundtrippedMessage.getRole());
273+
}
274+
}

0 commit comments

Comments
 (0)