diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/main/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutor.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/main/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutor.java index 909a19f587..27aca98648 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/main/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutor.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/main/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutor.java @@ -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; } @@ -139,7 +139,29 @@ private String getUserId(Message message) { return ""; } - private String getSessionId(Message message) { + /** + * Extract sessionId from the A2A request. + * + *

Resolution order: + *

    + *
  1. A2A protocol standard field: {@code Message.contextId} (via {@code RequestContext.getContextId()}).
  2. + *
  3. Backward-compatible fallback: {@code Message.metadata["sessionId"]}.
  4. + *
+ * + *

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")); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/test/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/test/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutorTest.java index 976d85a7a4..2f4f1fbcf7 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/test/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutorTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server/src/test/java/io/agentscope/core/a2a/server/executor/AgentScopeAgentExecutorTest.java @@ -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 { + // 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 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 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 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 {