Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ private AgentRequestOptions buildAgentRequestOptions(RequestContext context) {
AgentRequestOptions requestOptions = new AgentRequestOptions();
requestOptions.setTaskId(context.getTaskId());
requestOptions.setUserId(getUserId(message));
requestOptions.setSessionId(getSessionId(message));
requestOptions.setSessionId(getSessionId(context, message));
return requestOptions;
}

Expand All @@ -139,7 +139,29 @@ private String getUserId(Message message) {
return "";
}

private String getSessionId(Message message) {
/**
* Extract sessionId from the A2A request.
*
* <p>Resolution order:
* <ol>
* <li>A2A protocol standard field: {@code Message.contextId} (via {@code RequestContext.getContextId()}).</li>
* <li>Backward-compatible fallback: {@code Message.metadata["sessionId"]}.</li>
* </ol>
*
* <p>This ensures any standard A2A client that sets {@code contextId} on the message
* can be correctly routed to per-session state without requiring a custom metadata key.
*
* @param context the request context containing the resolved contextId
* @param message the original A2A message
* @return the sessionId, or empty string if not found
*/
private String getSessionId(RequestContext context, Message message) {
// Prefer the protocol-standard contextId field
String contextId = context.getContextId();
if (contextId != null && !contextId.isEmpty()) {
return contextId;
}
// Backward-compatible fallback: read from metadata
if (message.getMetadata() != null && message.getMetadata().containsKey("sessionId")) {
return String.valueOf(message.getMetadata().get("sessionId"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,150 @@ void testHandleExceptionDuringTaskCancellation() throws JSONRPCError {
}
}

@Nested
@DisplayName("Session ID Resolution Tests")
class SessionIdResolutionTests {

@Test
@DisplayName("Should use contextId as sessionId when contextId is present")
void testSessionIdFromContextId() throws JSONRPCError {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[recommended] Test coverage gap: The existing 3 tests cover contextId present, metadata fallback, and both present. Consider adding a test for the edge case where context.getContextId() returns null (not empty string) and no metadata sessionId exists — verifying the method returns empty string. This path is handled correctly in the code (contextId != null check) but has no test.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[nitpick] Test code duplication: The three test cases share extensive mock setup boilerplate (MessageSendParams mock, AtomicReference<AgentRequestOptions> capture, mockAgentRunner.stream stubbing, executor.execute call). Consider extracting a private helper method like executeAndCaptureOptions(contextId, metadataMap) to reduce duplication.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[recommended] Test coverage gap: The existing 3 tests cover contextId present, metadata fallback, and both present. Consider adding a test for the edge case where context.getContextId() returns null (not empty string) and no metadata sessionId exists — verifying the method returns empty string. This path is handled correctly in the code (contextId != null check) but has no test.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[nitpick] Test code duplication: The three test cases share extensive mock setup boilerplate (MessageSendParams mock, AtomicReference<AgentRequestOptions> capture, mockAgentRunner.stream stubbing, executor.execute call). Consider extracting a private helper method like executeAndCaptureOptions(contextId, metadataMap) to reduce duplication.

// Given: contextId is set, no metadata sessionId
String taskId = UUID.randomUUID().toString();
String contextId = "context-session-123";

when(mockContext.getTaskId()).thenReturn(taskId);
when(mockContext.getContextId()).thenReturn(contextId);

Message mockMessage = mock(Message.class);
when(mockMessage.getTaskId()).thenReturn(taskId);
when(mockMessage.getContextId()).thenReturn(contextId);
when(mockMessage.getParts()).thenReturn(List.of(new TextPart("hello")));
when(mockMessage.getMetadata()).thenReturn(null); // No metadata at all
when(mockContext.getMessage()).thenReturn(mockMessage);

MessageSendParams mockParams = mock(MessageSendParams.class);
when(mockContext.getParams()).thenReturn(mockParams);
when(mockParams.message()).thenReturn(mockMessage);

when(mockContext.getCallContext()).thenReturn(serverCallContext);
when(serverCallContext.getState()).thenReturn(Map.of());

AtomicReference<AgentRequestOptions> capturedOptions = new AtomicReference<>();
when(mockAgentRunner.stream(anyList(), any(AgentRequestOptions.class)))
.thenAnswer(
invocation -> {
capturedOptions.set(invocation.getArgument(1));
return Flux.just(
new Event(
EventType.REASONING,
Msg.builder().textContent("response").build(),
true));
});

doAnswer(invocation -> null).when(mockEventQueue).enqueueEvent(any(Message.class));

// When
executor.execute(mockContext, mockEventQueue);

// Then: sessionId should come from contextId
assertNotNull(capturedOptions.get());
assertEquals(contextId, capturedOptions.get().getSessionId());
}

@Test
@DisplayName("Should fallback to metadata sessionId when contextId is empty")
void testSessionIdFallbackToMetadata() throws JSONRPCError {
// Given: contextId is empty, metadata has sessionId
String taskId = UUID.randomUUID().toString();
String metadataSessionId = "metadata-session-456";

when(mockContext.getTaskId()).thenReturn(taskId);
when(mockContext.getContextId()).thenReturn(""); // Empty contextId

Message mockMessage = mock(Message.class);
when(mockMessage.getTaskId()).thenReturn(taskId);
when(mockMessage.getContextId()).thenReturn("");
when(mockMessage.getParts()).thenReturn(List.of(new TextPart("hello")));
when(mockMessage.getMetadata()).thenReturn(Map.of("sessionId", metadataSessionId));
when(mockContext.getMessage()).thenReturn(mockMessage);

MessageSendParams mockParams = mock(MessageSendParams.class);
when(mockContext.getParams()).thenReturn(mockParams);
when(mockParams.message()).thenReturn(mockMessage);

when(mockContext.getCallContext()).thenReturn(serverCallContext);
when(serverCallContext.getState()).thenReturn(Map.of());

AtomicReference<AgentRequestOptions> capturedOptions = new AtomicReference<>();
when(mockAgentRunner.stream(anyList(), any(AgentRequestOptions.class)))
.thenAnswer(
invocation -> {
capturedOptions.set(invocation.getArgument(1));
return Flux.just(
new Event(
EventType.REASONING,
Msg.builder().textContent("response").build(),
true));
});

doAnswer(invocation -> null).when(mockEventQueue).enqueueEvent(any(Message.class));

// When
executor.execute(mockContext, mockEventQueue);

// Then: sessionId should fallback to metadata
assertNotNull(capturedOptions.get());
assertEquals(metadataSessionId, capturedOptions.get().getSessionId());
}

@Test
@DisplayName("Should prefer contextId over metadata sessionId when both present")
void testContextIdTakesPrecedenceOverMetadata() throws JSONRPCError {
// Given: both contextId and metadata sessionId are set
String taskId = UUID.randomUUID().toString();
String contextId = "context-id-wins";
String metadataSessionId = "metadata-id-loses";

when(mockContext.getTaskId()).thenReturn(taskId);
when(mockContext.getContextId()).thenReturn(contextId);

Message mockMessage = mock(Message.class);
when(mockMessage.getTaskId()).thenReturn(taskId);
when(mockMessage.getContextId()).thenReturn(contextId);
when(mockMessage.getParts()).thenReturn(List.of(new TextPart("hello")));
when(mockMessage.getMetadata()).thenReturn(Map.of("sessionId", metadataSessionId));
when(mockContext.getMessage()).thenReturn(mockMessage);

MessageSendParams mockParams = mock(MessageSendParams.class);
when(mockContext.getParams()).thenReturn(mockParams);
when(mockParams.message()).thenReturn(mockMessage);

when(mockContext.getCallContext()).thenReturn(serverCallContext);
when(serverCallContext.getState()).thenReturn(Map.of());

AtomicReference<AgentRequestOptions> capturedOptions = new AtomicReference<>();
when(mockAgentRunner.stream(anyList(), any(AgentRequestOptions.class)))
.thenAnswer(
invocation -> {
capturedOptions.set(invocation.getArgument(1));
return Flux.just(
new Event(
EventType.REASONING,
Msg.builder().textContent("response").build(),
true));
});

doAnswer(invocation -> null).when(mockEventQueue).enqueueEvent(any(Message.class));

// When
executor.execute(mockContext, mockEventQueue);

// Then: contextId should take precedence
assertNotNull(capturedOptions.get());
assertEquals(contextId, capturedOptions.get().getSessionId());
}
}

@Test
@DisplayName("Should handle exception during execution")
void testHandleExceptionDuringExecution() throws JSONRPCError {
Expand Down
Loading