feat(tui): stream replies, cancel tasks, and handle input-required - #98
Merged
Conversation
The TUI sent a message and blocked until the task finished. It never called `stream()`, `cancel_task()`, or `resubscribe()`, so streaming -- A2A's headline capability, advertised on the agent card -- was invisible in Handler's flagship interface, and a long-running task could not be stopped. The events were already there. `A2AService.send()` consumed a stream internally and kept only the final event, discarding every `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` along the way. **A turn layer.** Rather than bolt streaming onto `_send_message`, this adds `a2a_handler.turn`: one user message and the agent's streamed reply, modeled as `AgentTurn` yielding `TurnEvent`s and ending in a `TurnResult`. It owns the protocol semantics of an exchange -- when a turn is over, which task a follow-up continues, and what to do when a server refuses a stale task -- with no dependency on any user interface, so the CLI and MCP bridge can adopt it next and the rules stay in one tested place. The stale-task retry moved here out of the widget, where it was previously entangled with rendering. **INPUT_REQUIRED.** Nothing in Handler referenced this state. It is not terminal, so a turn that waits for a terminal state waits forever: an agent that pauses to ask a question left the client hanging. Turns now settle on the interrupted states as well, the input is released with a prompt explaining the agent is waiting, and the open task is kept so the reply continues it rather than starting a new one. AUTH_REQUIRED settles the same way and points at the Auth tab. **Cancel.** A STOP control appears while a turn is in flight. It sends a real `CancelTask` to the agent and releases the input. If the agent cannot cancel, that is said plainly rather than left ambiguous. Turns also stop consuming after a cancel request, so an agent that keeps streaming does not hold the UI. Replies now render as they arrive in a transient `StreamingMessage` that is replaced by a normal `AgentMessage` when the turn settles, so a finished transcript looks the same whether or not the reply arrived incrementally. Verified against a live A2A agent as well as by unit test: the turn layer drives a real exchange end to end, and the CLI's send and stream paths are unchanged. Note the test agent advertises `streaming: false`, so the live check exercises the non-streaming path; incremental rendering is covered by the TUI tests. 35 new tests: 25 for the turn layer, 3 for the TUI behaviors, and the existing TUI send tests rewritten to drive streams instead of a blocking send. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by testing the input-required path against a real agent for the first time: the TUI correctly released the composer and kept the task open, but the user could not see what had been asked. A task that pauses rather than finishes often has nothing in its artifacts and nothing from the agent in its history -- the question lives only on the status message. `extract_text_from_task` looked at artifacts, then history, then gave up, so the reply rendered as "(no text in response)" next to a prompt saying the agent was waiting. It now falls back to the status message when neither of the other two carries anything. Artifacts still win, so a task's real output is never displaced by incidental narration. This helps any agent that leaves its last word on the status, not just the paused case.
Cancelling a long task rendered the reply as "(no text in response)" directly underneath the progress the user had just watched stream past. A canceled task carries no artifacts and nothing from the agent in history, so there was genuinely nothing left to show once the live widget was removed -- the narration had only ever existed in the stream. The turn layer now keeps the last thing the agent said, and the final message falls back to it when the task itself has no text. Stopping a task now leaves "Working... step 2 of 40" with `state: canceled`, which is a truthful record of where it got to rather than a claim that nothing was said. This is the same failure the status-message fallback fixed for input-required, one layer further out: that one recovers text the task still carries, this one recovers text only the stream ever had.
Cancelling still showed "(no text in response)" for some timings. The previous fix kept the agent's narration, but whether it reached the screen depended on a race: STOP asked the agent to cancel and immediately killed the worker, so either the agent's `canceled` status arrived first and the turn settled normally, or the worker died first and the turn ended with no task at all. Measured across three cancel timings, all three outcomes were reachable -- narration rendered, no agent message whatsoever, and a canceled task with nothing to show. STOP now asks the agent, waits up to two seconds for it to acknowledge, and only pulls the worker if it does not. An acknowledged cancel settles the turn the ordinary way, keeping the task, its metadata, and everything the agent said. The composer comes back either way. Two smaller gaps closed alongside it: - A turn stopped before the agent produced a task no longer drops the exchange; the narration is kept as an agent message. - A reply with genuinely no text now says which kind of nothing it is -- "(stopped before the agent replied)" for a cancel, "(the task failed without a message)" for a failure. Claiming an agent said nothing is misleading when the user watched it get stopped mid-sentence.
This was referenced Aug 16, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First step toward A2A parity. The TUI sent a message and blocked until the task finished — it never called
stream(),cancel_task(), orresubscribe(). Streaming is A2A's headline capability and the agent card advertises it, but Handler's flagship interface showed nothing until a task completed and offered no way to stop one.The events were already there.
A2AService.send()consumed a stream internally and kept only the final event, discarding everyTaskStatusUpdateEventandTaskArtifactUpdateEventon the way past.A turn layer, not a bigger
_send_message_send_messagewas ~90 lines mixing widget access, credential syncing, protocol retry logic, state mutation, session persistence, and rendering. Adding streaming there would have made it worse.Instead this adds
a2a_handler.turn: one user message and the agent's streamed reply, asAgentTurnyieldingTurnEvents and ending in aTurnResult. It owns the protocol semantics of an exchange — when a turn is over, which task a follow-up continues, what to do when a server refuses a stale task — with no dependency on any user interface.That matters for what's next: the CLI and MCP bridge can adopt the same rules instead of reimplementing them, and the rules stay in one tested place. The stale-task retry moved here out of the widget, where it had been tangled up with rendering. 25 of the 35 new tests are plain async tests with no Textual involved.
INPUT_REQUIRED— a correctness bug, not a missing featureNothing in Handler referenced this state. It isn't terminal, so a turn that waits for a terminal state waits forever: an agent that paused to ask a question left the client hanging with the input disabled.
Turns now settle on the interrupted states as well as the terminal ones. The input is released with a prompt explaining the agent is waiting, and the open task is kept so the reply continues it rather than starting a new thread.
AUTH_REQUIREDsettles the same way and points at the Auth tab.Cancel
A STOP control appears while a turn is in flight, sends a real
CancelTask, and releases the input. If the agent can't cancel, that's said plainly rather than left ambiguous. Turns also stop consuming after a cancel request, so an agent that keeps streaming can't hold the UI.Rendering
Replies stream into a transient
StreamingMessagethat is replaced by a normalAgentMessageonce the turn settles, so a finished transcript looks the same whether or not the reply arrived incrementally.Verification
450 tests pass (35 new), plus ruff,
ruff format, andty.The earlier round of this work could only be checked against an agent advertising
streaming: false, so the streaming path was covered by tests alone. That gap is now closed with a deterministic streaming agent (kept outside this repo — it needsgoogle-adk, which v0.2.0 deliberately removed). Against it:message streamcanceledwhen queried afterward, so it's a real protocol cancel, not a client-side abandonTASK_STATE_FAILEDwith the reason attachedmessage sendWorth knowing for anyone testing this: ADK's
AgentCardBuilderdefaults to a bareAgentCapabilities(), leavingstreamingfalse. That's why most ADK agents won't exercise this path — the card has to set it explicitly.Coverage
TUI method coverage goes 2/11 → 4/11 (
SendStreamingMessageandCancelTask).SubscribeToTaskis still unwired in the TUI.Not in this PR
Sending non-text parts,
ListTasks, the two missing push-notification config methods,GetExtendedAgentCard, transport negotiation, and the remaining auth schemes.🤖 Generated with Claude Code