[Bug]: AgentEmitter#requiresInput(..., isFinal=true) does not close the event stream · Issue #975 · a2aproject/a2a-java · GitHub
Skip to content

[Bug]: AgentEmitter#requiresInput(..., isFinal=true) does not close the event stream #975

Closed

Description

@bianjp

What happened?

Describe the bug

After #756, streams correctly stay open for interrupted states (INPUT_REQUIRED / AUTH_REQUIRED).

However, AgentEmitter#requiresInput(Message, boolean isFinal) and requiresAuth(..., boolean isFinal) accept an isFinal flag that appears to have no effect on stream completion. Passing isFinal=true still leaves the SSE / Flow.Publisher open (subscriber onComplete is never called).

Expected behavior

Either:

  1. requiresInput(message, true) / requiresAuth(message, true) closes the stream after the status event is delivered, or
  2. The isFinal parameter is documented as not controlling stream lifecycle (and ideally removed/deprecated if unused).

Actual behavior

In 1.1.0.Final, isFinal only sets the internal terminalStateReached flag in AgentEmitter#updateStatus. It is not written onto TaskStatusUpdateEvent.

EventConsumer terminates the stream when TaskStatusUpdateEvent#isFinal() is true, but that method only delegates to TaskState#isFinal(), which is always false for INPUT_REQUIRED / AUTH_REQUIRED.

So requiresInput(msg, true) and requiresInput(msg, false) behave the same for stream completion.

Related leftover: AgentEmitter#emitEvent Javadoc still shows TaskStatusUpdateEvent.builder()...isFinal(false), but the builder no longer has isFinal(...).

Steps to reproduce

  1. In an AgentExecutor, after work finishes for this turn, call:
    emitter.requiresInput(replyMessage, true);
  2. Subscribe to onMessageSendStream / JSON-RPC streaming response.
  3. Observe that events are delivered, but the publisher never completes / SSE connection stays open.

Version

Question

If keeping the stream open for interrupted states is intentional (per #756), what is the supported way for an agent to end the current stream after emitting INPUT_REQUIRED, when the client is expected to continue via a new request rather than the same open stream?

Relevant log output

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. added theissue type on Jul 9, 2026
  2. chopmob-cloud commented on Jul 13, 2026

    @chopmob-cloud
    Contributor

    Confirmed the trace, and it points at a clear root cause: the isFinal parameter no longer has anywhere to land on the wire.

    Walking it through on 1.1.0.Final:

    • requiresInput(message, isFinal) calls updateStatus(TASK_STATE_INPUT_REQUIRED, message, isFinal).
    • In updateStatus, isFinal only drives terminalStateReached.compareAndSet(false, true). The event itself is built as TaskStatusUpdateEvent.builder().taskId(...).contextId(...).status(new TaskStatus(state, message, null)).build(), with no finality carried onto it.
    • TaskStatusUpdateEvent.isFinal() returns status.state().isFinal(), i.e. it is derived purely from the state.
    • EventConsumer decides completion on tue.isFinal() (event instanceof TaskStatusUpdateEvent tue && tue.isFinal()), and for INPUT_REQUIRED / AUTH_REQUIRED TaskState.isFinal() is false (correctly, per fix: Keep stream open on interrupted state changes #756: interrupted states are not terminal).

    So isFinal=true does two things that have quietly come apart: it seals the emitter against further updates (via terminalStateReached, so a later updateStatus throws), but it does not finalize the event or complete the stream, because the only thing EventConsumer reads is state-derived finality.

    This is not purely cosmetic. Because the intended finalization never completes the publisher, connections an agent asked to release stay open, so a server handling many concurrent interrupted turns keeps SSE / Flow.Publisher resources it was told to free. That is a mild availability cost that only shows up under load, and it leans toward actually wiring the flag rather than only documenting it.

    The reason the flag is stranded is that in the v1.0 spec TaskStatusUpdateEvent has no explicit final field (task_id, context_id, status, metadata only in specification/a2a.proto), so finality became a function of the state. The isFinal parameter is a leftover from the earlier model where the event carried its own final bit. That is also why the emitEvent Javadoc still shows TaskStatusUpdateEvent.builder()...isFinal(false), a builder call that no longer exists.

    Which fix fits depends on the intended contract, and the two are not equivalent:

    1. If an agent is meant to be able to end the current stream after INPUT_REQUIRED (your question, and the useful case), the stream-close signal has to be separate from state-derived finality, since fix: Keep stream open on interrupted state changes #756 deliberately keeps interrupted states open. Concretely: carry the caller's isFinal onto the enqueued event (or a distinct terminal marker) and have EventConsumer complete when that is set, independent of state().isFinal(). That preserves fix: Keep stream open on interrupted state changes #756's default (interrupted stays open) while letting the agent opt to close and have the client resume via a new request.

    2. If the intended contract is that interrupted streams always stay open and the client always resumes on a fresh request, then isFinal on requiresInput / requiresAuth is misleading and should be deprecated or removed, with the lifecycle documented, so callers are not led to expect a completion that never comes.

    Option 1 answers the "how does the agent free the connection" case; option 2 only closes the documentation gap. Either way terminalStateReached and stream completion are two separate concepts today, and the bug is that isFinal currently reaches only the first.

  3. kabir commented on Jul 13, 2026

    @kabir
    Collaborator

    Hi @chopmob-cloud - I'm on my own this week, and pulled off into other things where I cover for people on holiday.

    Would you be able to use your analysis to open a PR? :-)

  4. chopmob-cloud commented on Jul 13, 2026

    @chopmob-cloud
    Contributor

    Done, opened #980. It takes option 1 (wire isFinal to actually close the stream, reusing the existing QueueClosedEvent path so interrupted states still stay open by default per #756). Verified on JDK 17, server-common suite green. Glad to switch it to the deprecate/document approach instead if you'd prefer that.

  5. bianjp commented on Jul 16, 2026

    @bianjp
    Author

    Cross-SDK note: a2a-python closes the stream on INPUT_REQUIRED

    In a2a-python, emitting INPUT_REQUIRED effectively ends the current request stream. This differs from a2a-java after #756 (stream stays open for interrupted states), and there is currently no working way in Java to opt into the Python behavior (requiresInput(..., true) does not close the stream).

    1. Legacy path — EventConsumer treats INPUT_REQUIRED as final

    EventConsumer.consume_all includes TASK_STATE_INPUT_REQUIRED in the final-state set, then closes the queue and stops consumption:

    is_final_event = isinstance(event, Message) or (
        isinstance(event, Task | TaskStatusUpdateEvent)
        and event.status.state
        in (
            TaskState.TASK_STATE_COMPLETED,
            TaskState.TASK_STATE_CANCELED,
            TaskState.TASK_STATE_FAILED,
            TaskState.TASK_STATE_REJECTED,
            TaskState.TASK_STATE_UNSPECIFIED,
            TaskState.TASK_STATE_INPUT_REQUIRED,  # closes stream
        )
    )
    
    if is_final_event:
        await self.queue.close(True)
        yield event
        break

    2. Current default path — V2 ends the subscriber when execute() returns

    DefaultRequestHandler is aliased to V2:

    The executor contract says that for INPUT_REQUIRED the agent should publish the status update and return to yield control:

    When execute() returns, the producer enqueues _RequestCompleted:

    And ActiveTask.subscribe returns on matching _RequestCompleted, ending the stream for that request:

    So the Python client interaction is roughly:

    1. Receive INPUT_REQUIRED → current stream ends
    2. Gather input
    3. Send a follow-up message with the same taskId (new stream)

    Suggestion for a2a-java

    Please consider either:

    1. Align with a2a-python: close the current stream after emitting INPUT_REQUIRED (client continues via a new request), or
    2. Keep fix: Keep stream open on interrupted state changes #756 behavior, but provide a supported API to close the stream after an interrupted state — and either honor requiresInput(..., isFinal=true) / requiresAuth(..., isFinal=true), or remove/deprecate that parameter and document the intended close API.

    Happy to help validate either approach from a server-embedding perspective.

  6. chopmob-cloud commented on Jul 21, 2026

    @chopmob-cloud
    Contributor

    Thanks for the cross-SDK writeup, that is a useful comparison to have on record.

    #980 already takes the second option you outlined: keep #756's default (interrupted states stay open), and add a supported way to close the stream when the agent wants to, by honoring requiresInput/requiresAuth isFinal=true instead of leaving it stranded. It reuses the existing QueueClosedEvent path, so a client that gets INPUT_REQUIRED with isFinal=true can resume via a new request, same as the Python behavior you described, while the default for callers not asking for isFinal is unchanged.

    It is still open and awaiting review. Flagging here in case it helps move it forward, since it should close this issue once it lands.

  7. kabir commented on Jul 22, 2026

    @kabir
    Collaborator

    I have opened #989 as an alternative PR for the reasons mentioned in #980 (comment)

  8. bianjp commented on Jul 22, 2026

    @bianjp
    Author

    I have opened #989 as an alternative PR for the reasons mentioned in #980 (comment)

    Thanks @kabir — deprecating the isFinal overloads sounds good.

    We still need a way to close the stream after INPUT_REQUIRED (to free SSE connections; clients can resume via a new request). Do you have a rough timeline or tracking issue for that dedicated close API?

  9. kabir commented on Jul 22, 2026

    @kabir
    Collaborator

    @bianjp I don't know exactly what that would look like yet.

    I guess the issue is that if the client doesn't react to either of these interrupted states, the stream stays open and never closes?

    OTOH I don't think there is anything wrong with a client being subscribed to the stream until the end, and farming off sending input/auth via a synchronous sendMessage() call when it receives these events.

    Also, we could have the case where a lot of clients are subscribed to the stream while a Task takes days to complete.

    Maybe what we need is some kind of hook that is notified when the events happen, and provide an API for users to close the stream. That way users could handle it the way they see fit.

    Just some initial thoughts

  10. bianjp commented on Jul 22, 2026

    @bianjp
    Author

    I guess the issue is that if the client doesn't react to either of these interrupted states, the stream stays open and never closes?

    @kabir Yes — that’s the issue. With SSE, only the server can complete the stream. The client can’t cleanly close it; dropping the connection still leaves the server-side publisher/resources open until timeout.

    Also, SSE is one-way, so after INPUT_REQUIRED the client has to send the follow-up via a new request (e.g. sendMessage) anyway — keeping the original stream open doesn’t help that path.

    Would you prefer I open a new issue to track an explicit close/hook API for this?

  11. kabir commented on Jul 22, 2026

    @kabir
    Collaborator

    @bianjp Yes please :-) Please include a bit of the stuff we discussed.

    BTW I'll be away for a few weeks from Friday, so I doubt much will happen before I'm back

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions