Skip to content

Commit 556b28b

Browse files
committed
fix: Keep stream open on interrupted state changes
1 parent 03e4458 commit 556b28b

4 files changed

Lines changed: 192 additions & 29 deletions

File tree

server-common/src/main/java/io/a2a/server/events/EventConsumer.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,21 @@ public Flow.Publisher<EventQueueItem> consumeAll() {
195195

196196
/**
197197
* Determines if a task is in a state for terminating the stream.
198-
* <p>A task is terminating if:</p>
199-
* <ul>
200-
* <li>Its state is final (e.g., completed, canceled, rejected, failed), OR</li>
201-
* <li>Its state is interrupted (e.g., input-required)</li>
202-
* </ul>
198+
* <p>
199+
* Per A2A Protocol Specification 3.1.6 (SubscribeToTask):
200+
* "The stream MUST terminate when the task reaches a terminal state
201+
* (completed, failed, canceled, or rejected)."
202+
* <p>
203+
* Interrupted states (INPUT_REQUIRED, AUTH_REQUIRED) are NOT terminal.
204+
* The stream should remain open to deliver future state updates when
205+
* the task resumes after receiving the required input or authorization.
206+
*
203207
* @param task the task to check
204-
* @return true if the task has a final state or an interrupted state, false otherwise
208+
* @return true if the task has a terminal/final state, false otherwise
205209
*/
206210
private boolean isStreamTerminatingTask(Task task) {
207211
TaskState state = task.status().state();
208-
return state.isFinal() || state == TaskState.TASK_STATE_INPUT_REQUIRED;
212+
return state.isFinal();
209213
}
210214

211215
public EnhancedRunnable.DoneCallback createAgentRunnableDoneCallback() {

server-common/src/test/java/io/a2a/server/events/EventConsumerTest.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,26 +238,32 @@ public void testConsumeMessageEvents() throws Exception {
238238

239239
@Test
240240
public void testConsumeTaskInputRequired() {
241+
// Per A2A Protocol Specification 3.1.6 (SubscribeToTask):
242+
// "The stream MUST terminate when the task reaches a terminal state
243+
// (completed, failed, canceled, or rejected)."
244+
//
245+
// INPUT_REQUIRED is an interrupted state, NOT a terminal state.
246+
// The stream should remain open to deliver future state updates.
241247
Task task = Task.builder()
242248
.id(TASK_ID)
243249
.contextId("session-xyz")
244250
.status(new TaskStatus(TaskState.TASK_STATE_INPUT_REQUIRED))
245251
.build();
246-
List<Event> events = List.of(
247-
task,
248-
TaskArtifactUpdateEvent.builder()
252+
TaskArtifactUpdateEvent artifactEvent = TaskArtifactUpdateEvent.builder()
249253
.taskId(TASK_ID)
250254
.contextId("session-xyz")
251255
.artifact(Artifact.builder()
252256
.artifactId("11")
253257
.parts(new TextPart("text"))
254258
.build())
255-
.build(),
256-
TaskStatusUpdateEvent.builder()
259+
.build();
260+
TaskStatusUpdateEvent completedEvent = TaskStatusUpdateEvent.builder()
257261
.taskId(TASK_ID)
258262
.contextId("session-xyz")
259263
.status(new TaskStatus(TaskState.TASK_STATE_COMPLETED))
260-
.build());
264+
.build();
265+
List<Event> events = List.of(task, artifactEvent, completedEvent);
266+
261267
for (Event event : events) {
262268
eventQueue.enqueueEvent(event);
263269
}
@@ -269,9 +275,12 @@ public void testConsumeTaskInputRequired() {
269275
publisher.subscribe(getSubscriber(receivedEvents, error));
270276

271277
assertNull(error.get());
272-
// The stream is closed after the input_required task
273-
assertEquals(1, receivedEvents.size());
278+
// Stream should remain open for INPUT_REQUIRED and deliver all events
279+
// until the terminal COMPLETED state is reached
280+
assertEquals(3, receivedEvents.size());
274281
assertSame(task, receivedEvents.get(0));
282+
assertSame(artifactEvent, receivedEvents.get(1));
283+
assertSame(completedEvent, receivedEvents.get(2));
275284
}
276285

277286
private Flow.Subscriber<EventQueueItem> getSubscriber(List<Event> receivedEvents, AtomicReference<Throwable> error) {

spec/src/main/java/io/a2a/spec/TaskState.java

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@
66
* TaskState represents the discrete states a task can be in during its execution lifecycle.
77
* States are categorized as either transitional (non-final) or terminal (final), where
88
* terminal states indicate that the task has reached its end state and will not transition further.
9+
* A subset of transitional states are also marked as interrupted, indicating the task execution
10+
* has paused and requires external action before proceeding.
911
* <p>
10-
* <b>Transitional States:</b>
12+
* <b>Active Transitional States:</b>
1113
* <ul>
1214
* <li><b>TASK_STATE_SUBMITTED:</b> Task has been received by the agent and is queued for processing</li>
1315
* <li><b>TASK_STATE_WORKING:</b> Agent is actively processing the task and may produce incremental results</li>
16+
* </ul>
17+
* <p>
18+
* <b>Interrupted States:</b>
19+
* <ul>
1420
* <li><b>TASK_STATE_INPUT_REQUIRED:</b> Agent needs additional input from the user to continue</li>
1521
* <li><b>TASK_STATE_AUTH_REQUIRED:</b> Agent requires authentication or authorization before proceeding</li>
1622
* </ul>
@@ -25,44 +31,47 @@
2531
* </ul>
2632
* <p>
2733
* The {@link #isFinal()} method can be used to determine if a state is terminal, which is
28-
* important for event queue management and client polling logic.
34+
* important for event queue management and client polling logic. The {@link #isInterrupted()}
35+
* method identifies states where the task is paused awaiting external action.
2936
*
3037
* @see TaskStatus
3138
* @see Task
3239
* @see <a href="https://a2a-protocol.org/latest/">A2A Protocol Specification</a>
3340
*/
3441
public enum TaskState {
3542
/** Task has been received and is queued for processing (transitional state). */
36-
TASK_STATE_SUBMITTED(false),
43+
TASK_STATE_SUBMITTED(false, false),
3744

3845
/** Agent is actively processing the task (transitional state). */
39-
TASK_STATE_WORKING(false),
46+
TASK_STATE_WORKING(false, false),
4047

41-
/** Agent requires additional input from the user to continue (transitional state). */
42-
TASK_STATE_INPUT_REQUIRED(false),
48+
/** Agent requires additional input from the user to continue (interrupted state). */
49+
TASK_STATE_INPUT_REQUIRED(false, true),
4350

44-
/** Agent requires authentication or authorization to proceed (transitional state). */
45-
TASK_STATE_AUTH_REQUIRED(false),
51+
/** Agent requires authentication or authorization to proceed (interrupted state). */
52+
TASK_STATE_AUTH_REQUIRED(false, true),
4653

4754
/** Task completed successfully (terminal state). */
48-
TASK_STATE_COMPLETED(true),
55+
TASK_STATE_COMPLETED(true, false),
4956

5057
/** Task was canceled by user or system (terminal state). */
51-
TASK_STATE_CANCELED(true),
58+
TASK_STATE_CANCELED(true, false),
5259

5360
/** Task failed due to an error (terminal state). */
54-
TASK_STATE_FAILED(true),
61+
TASK_STATE_FAILED(true, false),
5562

5663
/** Task was rejected by the agent (terminal state). */
57-
TASK_STATE_REJECTED(true),
64+
TASK_STATE_REJECTED(true, false),
5865

5966
/** Task state is unknown or cannot be determined (terminal state). */
60-
UNRECOGNIZED(true);
67+
UNRECOGNIZED(true, false);
6168

6269
private final boolean isFinal;
70+
private final boolean isInterrupted;
6371

64-
TaskState(boolean isFinal) {
72+
TaskState(boolean isFinal, boolean isInterrupted) {
6573
this.isFinal = isFinal;
74+
this.isInterrupted = isInterrupted;
6675
}
6776

6877
/**
@@ -71,10 +80,32 @@ public enum TaskState {
7180
* Terminal states indicate that the task has completed its lifecycle and will
7281
* not transition to any other state. This is used by the event queue system
7382
* to determine when to close queues and by clients to know when to stop polling.
83+
* <p>
84+
* Terminal states: COMPLETED, FAILED, CANCELED, REJECTED, UNRECOGNIZED.
7485
*
7586
* @return {@code true} if this is a terminal state, {@code false} else.
7687
*/
7788
public boolean isFinal(){
7889
return isFinal;
7990
}
91+
92+
/**
93+
* Determines whether this state is an interrupted state.
94+
* <p>
95+
* Interrupted states indicate that the task execution has paused and requires
96+
* external action before proceeding. The task may resume after the required
97+
* action is provided. Interrupted states are NOT terminal - streams should
98+
* remain open to deliver state updates.
99+
* <p>
100+
* Interrupted states: INPUT_REQUIRED, AUTH_REQUIRED.
101+
* <p>
102+
* Per A2A Protocol Specification 4.1.3 (TaskState):
103+
* "TASK_STATE_INPUT_REQUIRED: This is an interrupted state."
104+
* "TASK_STATE_AUTH_REQUIRED: This is an interrupted state."
105+
*
106+
* @return {@code true} if this is an interrupted state, {@code false} else.
107+
*/
108+
public boolean isInterrupted() {
109+
return isInterrupted;
110+
}
80111
}

tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,125 @@ public void testSubscribeExistingTaskSuccessWithClientConsumers() throws Excepti
875875
}
876876
}
877877

878+
/**
879+
* Tests that SubscribeToTask stream stays open for interrupted states (INPUT_REQUIRED, AUTH_REQUIRED)
880+
* and only terminates on terminal states.
881+
* <p>
882+
* Per A2A Protocol Specification 3.1.6 (SubscribeToTask):
883+
* "The stream MUST terminate when the task reaches a terminal state (completed, failed, canceled, or rejected)."
884+
* <p>
885+
* Interrupted states are NOT terminal - the stream should remain open to deliver future state updates.
886+
* <p>
887+
* This test addresses issue #754: Stream was incorrectly closing immediately for INPUT_REQUIRED state.
888+
*/
889+
@Test
890+
@Timeout(value = 3, unit = TimeUnit.MINUTES)
891+
public void testSubscribeToTaskWithInterruptedStateKeepsStreamOpen() throws Exception {
892+
// Create task in INPUT_REQUIRED state (interrupted, not terminal)
893+
Task inputRequiredTask = Task.builder()
894+
.id("task-input-required-" + UUID.randomUUID())
895+
.contextId("session-xyz")
896+
.status(new TaskStatus(TaskState.TASK_STATE_INPUT_REQUIRED))
897+
.build();
898+
899+
saveTaskInTaskStore(inputRequiredTask);
900+
try {
901+
ensureQueueForTask(inputRequiredTask.id());
902+
903+
// Track events received through the stream
904+
CopyOnWriteArrayList<io.a2a.spec.UpdateEvent> receivedEvents = new CopyOnWriteArrayList<>();
905+
AtomicBoolean receivedInitialTask = new AtomicBoolean(false);
906+
AtomicBoolean streamClosedPrematurely = new AtomicBoolean(false);
907+
AtomicReference<Throwable> errorRef = new AtomicReference<>();
908+
CountDownLatch completionLatch = new CountDownLatch(1);
909+
910+
// Consumer to track all events
911+
BiConsumer<ClientEvent, AgentCard> consumer = (event, agentCard) -> {
912+
if (event instanceof TaskEvent taskEvent) {
913+
if (!receivedInitialTask.get()) {
914+
receivedInitialTask.set(true);
915+
// First event should be the initial task snapshot
916+
return;
917+
}
918+
} else if (event instanceof TaskUpdateEvent taskUpdateEvent) {
919+
io.a2a.spec.UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent();
920+
receivedEvents.add(updateEvent);
921+
922+
// Check if this is the final terminal state
923+
if (updateEvent instanceof TaskStatusUpdateEvent tue && tue.isFinal()) {
924+
completionLatch.countDown();
925+
}
926+
}
927+
};
928+
929+
// Error handler to detect premature stream closure
930+
Consumer<Throwable> errorHandler = error -> {
931+
if (!isStreamClosedError(error)) {
932+
errorRef.set(error);
933+
}
934+
// If completion latch hasn't been counted down yet, stream closed prematurely
935+
if (completionLatch.getCount() > 0) {
936+
streamClosedPrematurely.set(true);
937+
}
938+
completionLatch.countDown();
939+
};
940+
941+
// Subscribe to the task
942+
CountDownLatch subscriptionLatch = new CountDownLatch(1);
943+
awaitStreamingSubscription()
944+
.whenComplete((unused, throwable) -> subscriptionLatch.countDown());
945+
946+
getClient().subscribeToTask(new TaskIdParams(inputRequiredTask.id()), List.of(consumer), errorHandler);
947+
948+
// Wait for subscription to be established
949+
assertTrue(subscriptionLatch.await(15, TimeUnit.SECONDS), "Subscription should be established");
950+
951+
// Wait a bit to ensure stream doesn't close prematurely
952+
Thread.sleep(500);
953+
954+
// Verify stream is still open (no premature closure)
955+
assertFalse(streamClosedPrematurely.get(),
956+
"Stream should NOT close for INPUT_REQUIRED state (interrupted, not terminal)");
957+
958+
// Send status update to WORKING (still non-terminal)
959+
enqueueEventOnServer(TaskStatusUpdateEvent.builder()
960+
.taskId(inputRequiredTask.id())
961+
.contextId(inputRequiredTask.contextId())
962+
.status(new TaskStatus(TaskState.TASK_STATE_WORKING))
963+
.build());
964+
965+
// Wait a bit and verify stream is still open
966+
Thread.sleep(500);
967+
assertFalse(streamClosedPrematurely.get(),
968+
"Stream should remain open after transitioning to WORKING");
969+
970+
// Send terminal status update to COMPLETED
971+
enqueueEventOnServer(TaskStatusUpdateEvent.builder()
972+
.taskId(inputRequiredTask.id())
973+
.contextId(inputRequiredTask.contextId())
974+
.status(new TaskStatus(TaskState.TASK_STATE_COMPLETED))
975+
.build());
976+
977+
// Now stream should close
978+
assertTrue(completionLatch.await(30, TimeUnit.SECONDS),
979+
"Stream should close after terminal state");
980+
981+
// Verify we received both updates before stream closed
982+
assertEquals(2, receivedEvents.size(),
983+
"Should receive both status updates before stream closes");
984+
985+
TaskStatusUpdateEvent firstUpdate = (TaskStatusUpdateEvent) receivedEvents.get(0);
986+
assertEquals(TaskState.TASK_STATE_WORKING, firstUpdate.status().state());
987+
988+
TaskStatusUpdateEvent secondUpdate = (TaskStatusUpdateEvent) receivedEvents.get(1);
989+
assertEquals(TaskState.TASK_STATE_COMPLETED, secondUpdate.status().state());
990+
991+
assertNull(errorRef.get(), "Should not have any errors");
992+
} finally {
993+
deleteTaskInTaskStore(inputRequiredTask.id());
994+
}
995+
}
996+
878997
@Test
879998
public void testSubscribeNoExistingTaskError() throws Exception {
880999
CountDownLatch errorLatch = new CountDownLatch(1);

0 commit comments

Comments
 (0)