Repository: langchain4j/langchain4j (2026-07-27T05:02:24Z)

Issue:
[BUG] A2A agent call hangs when the stream ends without a result
**Describe the bug**

On the streaming path, `DefaultA2AClientBuilder.invokeAgent()` passes an error handler to `Client.sendMessage(...)`. The A2A SDK calls that handler with `null` to report a normal end of the stream (`SSEEventListener.onComplete()`: *"null error means successful completion"*).

The handler dereferences its argument unconditionally. If the stream ends before any event completed the response future, `error.getMessage()` throws a `NullPointerException`, so `completeExceptionally(error)` is never reached: the future stays incomplete and `messageResponse.get()`, which has no timeout, blocks forever.

**Log and Stack trace**

None — the SDK's HTTP client plumbing swallows the `NullPointerException`. The only symptom is a call that never returns.

**To Reproduce**

Call an agent whose server advertises `capabilities.streaming = true` and closes the SSE stream without a terminal task state:

```java
MyAgent agent = AgenticServices.a2aBuilder(a2aServerUrl, MyAgent.class).build();
agent.chat("hello"); // never returns
```

**Expected behavior**

The call fails with an exception reporting that the stream closed before a result arrived.

**Please complete the following information:**
- LangChain4j version: 1.19.0-SNAPSHOT, A2A SDK 1.1.0.Final
- LLM(s) used: N/A
- Java version: 17
- Spring Boot version (if applicable): N/A

**Additional context**

This fix should land first: a follow-up for interrupted task states (`input-required`, `auth-required`) delays completion further and widens the set of hanging calls.




Discussion:

Patch:
diff --git a/langchain4j-agentic-a2a/src/main/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilder.java b/langchain4j-agentic-a2a/src/main/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilder.java
index 5e7bf97d1f1..e29f0f83490 100644
--- a/langchain4j-agentic-a2a/src/main/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilder.java
+++ b/langchain4j-agentic-a2a/src/main/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilder.java
@@ -232,14 +232,7 @@ private A2AInvocationResult invokeAgent(Method method, Type returnType, Object[]
                         new IllegalArgumentException("The event expected should be of type " + event.getClass()));
             }
         });
-        Consumer<Throwable> streamingErrorHandler = error -> {
-            if (messageResponse.isDone()) {
-                LOG.debug("SSE stream closed after response received: {}", error.getMessage());
-            } else {
-                LOG.error("Streaming error occurred: {}", error.getMessage(), error);
-                messageResponse.completeExceptionally(error);
-            }
-        };
+        Consumer<Throwable> streamingErrorHandler = error -> handleStreamEnd(error, messageResponse);
         a2aClient.sendMessage(message, consumers, streamingErrorHandler, null);
 
         String finalContextIdKey = contextIdKey;
@@ -265,6 +258,24 @@ private static void captureTaskIds(Task task, AtomicReference<String> contextId,
         taskId.set(task.id());
     }
 
+    static void handleStreamEnd(Throwable error, CompletableFuture<String> messageResponse) {
+        if (error == null) {
+            // The A2A SDK reports a normal end of the stream by passing a null error.
+            LOG.debug("SSE stream closed normally");
+            if (!messageResponse.isDone()) {
+                messageResponse.completeExceptionally(
+                        new RuntimeException("A2A stream closed before a result was received"));
+            }
+            return;
+        }
+        if (messageResponse.isDone()) {
+            LOG.debug("SSE stream closed after response received: {}", error.getMessage());
+        } else {
+            LOG.error("Streaming error occurred: {}", error.getMessage(), error);
+            messageResponse.completeExceptionally(error);
+        }
+    }
+
     static void completeFromTask(Task task, CompletableFuture<String> messageResponse) {
         TaskState state = task.status().state();
         if (!isTerminalState(state) && task.artifacts().isEmpty()) {


Test patch:
diff --git a/langchain4j-agentic-a2a/src/test/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilderTest.java b/langchain4j-agentic-a2a/src/test/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilderTest.java
index 9fa3845fe47..3cb1455a554 100644
--- a/langchain4j-agentic-a2a/src/test/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilderTest.java
+++ b/langchain4j-agentic-a2a/src/test/java/dev/langchain4j/agentic/a2a/DefaultA2AClientBuilderTest.java
@@ -91,4 +91,47 @@ void completeFromTask_completedTaskWithEmptyArtifacts_completesNormallyWithEmpty
         assertThat(future).isCompleted();
         assertThat(future.get()).isEmpty();
     }
+
+    @Test
+    void handleStreamEnd_streamEndsWithoutResult_completesExceptionally() {
+        CompletableFuture<String> future = new CompletableFuture<>();
+
+        DefaultA2AClientBuilder.handleStreamEnd(null, future);
+
+        assertThat(future).isCompletedExceptionally();
+        assertThatThrownBy(future::get)
+                .hasCauseInstanceOf(RuntimeException.class)
+                .hasMessageContaining("A2A stream closed before a result was received");
+    }
+
+    @Test
+    void handleStreamEnd_streamEndsAfterResult_keepsResult() throws Exception {
+        CompletableFuture<String> future = CompletableFuture.completedFuture("the answer");
+
+        DefaultA2AClientBuilder.handleStreamEnd(null, future);
+
+        assertThat(future).isCompleted();
+        assertThat(future.get()).isEqualTo("the answer");
+    }
+
+    @Test
+    void handleStreamEnd_errorBeforeResult_completesExceptionallyWithThatError() {
+        CompletableFuture<String> future = new CompletableFuture<>();
+        RuntimeException error = new RuntimeException("connection reset");
+
+        DefaultA2AClientBuilder.handleStreamEnd(error, future);
+
+        assertThat(future).isCompletedExceptionally();
+        assertThatThrownBy(future::get).hasCause(error);
+    }
+
+    @Test
+    void handleStreamEnd_errorAfterResult_keepsResult() throws Exception {
+        CompletableFuture<String> future = CompletableFuture.completedFuture("the answer");
+
+        DefaultA2AClientBuilder.handleStreamEnd(new RuntimeException("connection reset"), future);
+
+        assertThat(future).isCompleted();
+        assertThat(future.get()).isEqualTo("the answer");
+    }
 }