Skip to content

feat(tui): stream replies, cancel tasks, and handle input-required - #98

Merged
alDuncanson merged 4 commits into
mainfrom
feat/tui-streaming
Aug 16, 2026
Merged

feat(tui): stream replies, cancel tasks, and handle input-required#98
alDuncanson merged 4 commits into
mainfrom
feat/tui-streaming

Conversation

@alDuncanson

Copy link
Copy Markdown
Owner

First step toward A2A parity. The TUI sent a message and blocked until the task finished — it never called stream(), cancel_task(), or resubscribe(). 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 every TaskStatusUpdateEvent and TaskArtifactUpdateEvent on the way past.

A turn layer, not a bigger _send_message

_send_message was ~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, as AgentTurn yielding TurnEvents and ending in a TurnResult. 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 feature

Nothing 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_REQUIRED settles 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 StreamingMessage that is replaced by a normal AgentMessage once 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, and ty.

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 needs google-adk, which v0.2.0 deliberately removed). Against it:

Checked Result
CLI message stream 8 events, 4 distinct progress updates arriving separately
TUI streaming Drove the real TUI headlessly; 4 distinct live updates observed as they arrived, final message rendered, transient widget removed, task ID cleared
Cancel Input released 0.29s after STOP — and the server reports canceled when queried afterward, so it's a real protocol cancel, not a client-side abandon
Failure path Streams a working update, then settles at TASK_STATE_FAILED with the reason attached
CLI message send Unchanged

Worth knowing for anyone testing this: ADK's AgentCardBuilder defaults to a bare AgentCapabilities(), leaving streaming false. 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 (SendStreamingMessage and CancelTask). SubscribeToTask is 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

alDuncanson and others added 4 commits August 14, 2026 22:15
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant