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
23 changes: 23 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,29 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<java>
<googleJavaFormat>
<version>1.22.0</version>
<style>GOOGLE</style>
</googleJavaFormat>
<removeUnusedImports/>
</java>
</configuration>
<executions>
<execution>
<id>spotless-check</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
Expand Down
2 changes: 2 additions & 0 deletions samples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@

<properties>
<maven.deploy.skip>true</maven.deploy.skip>
<!-- Demo module: formatting gate applies to the published modules only. -->
<spotless.skip>true</spotless.skip>
</properties>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
* <li>{@link RequestHandler} - the {@link DefaultRequestHandler}
* <li>{@link RestHandler} - the REST protocol handler
* <li>{@link MainEventBusProcessor} - the event bus
* <li>{@link A2AController} - the Spring MVC controller (message send/stream plus task
* query, list, cancel, and subscribe endpoints)
* <li>{@link A2AController} - the Spring MVC controller (message send/stream plus task query,
* list, cancel, and subscribe endpoints)
* <li>Optional slash-style aliases for gateways that cannot publish colon-style action paths
* </ul>
*
Expand Down Expand Up @@ -217,14 +217,12 @@ public A2AController a2aController(
}

/**
* Adds non-standard slash-style action aliases without changing the canonical A2A endpoints.
* This compatibility surface is disabled unless explicitly enabled for a constrained gateway.
* Adds non-standard slash-style action aliases without changing the canonical A2A endpoints. This
* compatibility surface is disabled unless explicitly enabled for a constrained gateway.
*/
@Bean
@ConditionalOnMissingBean(A2ASlashActionAliasController.class)
@ConditionalOnProperty(
name = "a2at.server.slash-action-aliases-enabled",
havingValue = "true")
@ConditionalOnProperty(name = "a2at.server.slash-action-aliases-enabled", havingValue = "true")
A2ASlashActionAliasController a2aSlashActionAliasController(A2AController delegate) {
return new A2ASlashActionAliasController(delegate);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ private SseEmitter subscribeToEmitter(
try {
context.invokeEventConsumerCancelCallback();
} catch (RuntimeException error) {
log.warn("[SSE] Failed to notify SDK event consumer cancellation: {}", error.getMessage());
log.warn(
"[SSE] Failed to notify SDK event consumer cancellation: {}", error.getMessage());
}
}
};
Expand Down Expand Up @@ -278,14 +279,14 @@ public ResponseEntity<String> cancelTask(
@PostMapping(
value = "${a2at.server.path-prefix}/tasks/{id}:subscribe",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribeToTask(
HttpServletRequest req, @PathVariable("id") String taskId) {
public SseEmitter subscribeToTask(HttpServletRequest req, @PathVariable("id") String taskId) {
try {
var ctx = buildContext(req);
requireStreamingSupport();
requestHandler.authorizeTaskAccess(taskId, ctx, TaskOperation.SUBSCRIBE_TO_TASK);
Flow.Publisher<StreamingEventKind> publisher =
requestHandler.onSubscribeToTask(TaskIdParams.builder().id(taskId).tenant("").build(), ctx);
requestHandler.onSubscribeToTask(
TaskIdParams.builder().id(taskId).tenant("").build(), ctx);
return subscribeToEmitter(publisher, ctx);
} catch (A2AError e) {
log.warn("[SSE] Task subscription rejected before stream creation: {}", e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ public class A2AProperties {
private String pathPrefix = "/a2a/json";

/**
* Whether to expose slash-style aliases for A2A action endpoints for gateways that reject
* colons in published paths. Standard colon-style endpoints always remain available.
* Whether to expose slash-style aliases for A2A action endpoints for gateways that reject colons
* in published paths. Standard colon-style endpoints always remain available.
*/
private boolean slashActionAliasesEnabled = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
/**
* Optional slash-style aliases for A2A action endpoints.
*
* <p>Every operation delegates to {@link A2AController}; the aliases therefore retain the
* canonical endpoint's authentication, A2A error envelope, streaming, and cleanup behavior.
* <p>Every operation delegates to {@link A2AController}; the aliases therefore retain the canonical
* endpoint's authentication, A2A error envelope, streaming, and cleanup behavior.
*/
@RestController
@ConditionalOnProperty(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/
package dev.openan.workflow.engine.spring;

import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand All @@ -29,12 +30,11 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.hamcrest.Matchers.containsString;

import java.lang.reflect.Modifier;
import java.util.concurrent.ExecutorService;
Expand All @@ -49,13 +49,12 @@
import org.a2aproject.sdk.server.requesthandlers.RequestHandler;
import org.a2aproject.sdk.spec.AgentCapabilities;
import org.a2aproject.sdk.spec.AgentCard;
import org.a2aproject.sdk.spec.TaskNotFoundError;
import org.a2aproject.sdk.spec.StreamingEventKind;
import org.a2aproject.sdk.spec.TaskNotFoundError;
import org.a2aproject.sdk.transport.rest.handler.RestHandler;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.http.ResponseEntity;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
Expand Down Expand Up @@ -169,63 +168,72 @@ public void cancel() {
assertEquals(0, controller.activeStreamCount());
}


@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(
strings = {"before-subscribe", "after-subscribe", "during-request"})
void synchronousPublisherFailureReleasesResourcesBeforeMvcDispatch(String phase) throws Exception {
void synchronousPublisherFailureReleasesResourcesBeforeMvcDispatch(String phase)
throws Exception {
for (String endpoint : new String[] {"/message:stream", "/tasks/task-1:subscribe"}) {
RestHandler restHandler = mock(RestHandler.class);
when(restHandler.createErrorResponse(any()))
.thenReturn(new RestHandler.HTTPRestResponse(
500, "application/a2a+json",
"{\"error\":{\"code\":500,\"status\":\"INTERNAL\",\"message\":\"Setup failed\"}}"));
.thenReturn(
new RestHandler.HTTPRestResponse(
500,
"application/a2a+json",
"{\"error\":{\"code\":500,\"status\":\"INTERNAL\",\"message\":\"Setup failed\"}}"));
RequestHandler requestHandler = mock(RequestHandler.class);
AgentCard agentCard = mock(AgentCard.class);
when(agentCard.capabilities()).thenReturn(AgentCapabilities.builder().streaming(true).build());
when(agentCard.capabilities())
.thenReturn(AgentCapabilities.builder().streaming(true).build());
var agentInterface = mock(org.a2aproject.sdk.spec.AgentInterface.class);
when(agentInterface.protocolVersion()).thenReturn("1.0");
when(agentCard.supportedInterfaces()).thenReturn(java.util.List.of(agentInterface));
AtomicInteger subscriptionCancellations = new AtomicInteger();
AtomicInteger eventConsumerCancellations = new AtomicInteger();
AtomicReference<Flow.Subscriber<? super StreamingEventKind>> subscriber = new AtomicReference<>();
AtomicReference<Flow.Subscriber<? super StreamingEventKind>> subscriber =
new AtomicReference<>();
org.mockito.stubbing.Answer<Flow.Publisher<StreamingEventKind>> answer =
invocation -> {
ServerCallContext context = invocation.getArgument(1);
context.setEventConsumerCancelCallback(eventConsumerCancellations::incrementAndGet);
return receiver -> {
subscriber.set(receiver);
if (!phase.equals("before-subscribe")) {
receiver.onSubscribe(new Flow.Subscription() {
@Override
public void request(long count) {
if (phase.equals("during-request")) {
throw new IllegalStateException("Request failed");
}
}

@Override
public void cancel() {
subscriptionCancellations.incrementAndGet();
}
});
receiver.onSubscribe(
new Flow.Subscription() {
@Override
public void request(long count) {
if (phase.equals("during-request")) {
throw new IllegalStateException("Request failed");
}
}

@Override
public void cancel() {
subscriptionCancellations.incrementAndGet();
}
});
}
throw new IllegalStateException("Subscribe failed");
};
};
when(requestHandler.onSubscribeToTask(any(), any())).thenAnswer(answer);
when(requestHandler.onMessageSendStream(any(), any())).thenAnswer(answer);
var controller = new A2AController(restHandler, requestHandler, agentCard);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.addPlaceholderValue("a2at.server.path-prefix", "/a2a/json")
.build();

mockMvc.perform(post("/a2a/json" + endpoint)
.header("A2A-Version", "1.0")
.contentType("application/json")
.accept("text/event-stream")
.content("{\"message\":{\"messageId\":\"m1\",\"role\":\"ROLE_USER\","
+ "\"parts\":[{\"text\":\"test\"}]}}"))
MockMvc mockMvc =
MockMvcBuilders.standaloneSetup(controller)
.addPlaceholderValue("a2at.server.path-prefix", "/a2a/json")
.build();

mockMvc
.perform(
post("/a2a/json" + endpoint)
.header("A2A-Version", "1.0")
.contentType("application/json")
.accept("text/event-stream")
.content(
"{\"message\":{\"messageId\":\"m1\",\"role\":\"ROLE_USER\","
+ "\"parts\":[{\"text\":\"test\"}]}}"))
.andExpect(status().isInternalServerError())
.andExpect(request().asyncNotStarted())
.andExpect(content().contentTypeCompatibleWith("application/a2a+json"))
Expand Down Expand Up @@ -414,8 +422,7 @@ void slashActionAliasesAreOptIn() {
.withBean(A2AController.class, () -> canonical)
.withPropertyValues("a2at.server.enabled=true");

runner.run(
context -> assertFalse(context.containsBean("a2aSlashActionAliasController")));
runner.run(context -> assertFalse(context.containsBean("a2aSlashActionAliasController")));
runner
.withPropertyValues("a2at.server.slash-action-aliases-enabled=true")
.run(context -> assertTrue(context.containsBean("a2aSlashActionAliasController")));
Expand Down
Loading