Skip to content

fix(ui): keep following a chat turn when its stream breaks - #3198

Merged
Charlesthebird merged 6 commits into
kagent-dev:mainfrom
garlicKim21:fix/ui-chat-stream-reattach
Oct 22, 2026
Merged

Charlesthebird merged 6 commits into
kagent-dev:mainfrom
garlicKim21:fix/ui-chat-stream-reattach

Conversation

@garlicKim21

@garlicKim21 garlicKim21 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #3197

When the chat stream (A2AService/SendStreamingMessage over gRPC-Web) breaks while the task is still running, the UI reports the turn as failed ("The agent could not finish this turn / network error"), even though the task keeps running on the server and completes, with its result in the task history.

This is easy to hit behind a proxy that closes quiet streams. A model reading a long prompt sends nothing for a minute or more, and an idle timeout somewhere on the path (an ingress, a reverse proxy, a CDN) ends the response. In our case the controller logged both streams as Canceled after exactly 60,015 ms. Both tasks finished one to two minutes after the cut, by which time the UI had already shown the error banner.

This change keeps following the task instead of failing the turn:

  • Once the stream has named its task (the first statusUpdate carries taskId), a transport failure re-attaches with SubscribeToTask. The gateway already implements it (go/core/internal/a2agateway/interactions.go). It is read-only and returns the stored task when the task has already finished, so the user's message is never sent twice.
  • Only transport failures are retried: Unknown (a bare network error, or a body that ends without its trailer) and Unavailable (HTTP 502/503/504). Errors the controller decided, a broken stream before any task id is known, a task that has already reached a terminal or input-required state, and a user abort behave as before.
  • Re-attaching gives up after three consecutive attempts that came to nothing, so a dead or flapping path still ends the turn. A re-attached stream opens with the task snapshot, so the count resets only on a frame after it, or when a stream stayed open for 10 s, long enough to have been cut for being idle. Attempts wait 0.5 s, 1 s and 2 s.

The change is confined to A2AGrpcChatClient.send. A task snapshot returned by the subscription goes through the existing task branch, which now records the artifacts it shows, so the next append chunk continues them, and shows artifacts again from every snapshot, so a later snapshot fills in chunks sent while the stream was down.

Testing

  • New unit tests in ui/src/api/chat/a2aGrpcChatClient.test.ts:
    • a stream that breaks after naming its task re-attaches once and ends with the completed task and its answer
    • a stream that breaks before naming a task does not re-attach
    • an error with a server-decided code (FailedPrecondition) still surfaces and does not re-attach
    • repeated breaks stop after three re-attach attempts
    • re-attaches that break right after the task snapshot stop after three attempts
    • re-attached streams cut after 60 s of quiet are followed to the end
    • a re-attach that delivers a frame after the snapshot resets the count
    • pressing stop while waiting to re-attach ends the turn without re-attaching
    • a reply first seen in the snapshot continues with the next append chunk instead of being replaced by it
    • a later snapshot fills in chunks a reply missed while the stream was down
  • The first and fourth tests fail without the change and pass with it. The fifth fails on e804f21, this PR's first commit, and passes on d3c7100. The ninth fails on d3c7100 and the tenth on 3da7a38; both pass on d98835c.
  • yarn typecheck: passes.
  • yarn lint: 0 errors; the same 6 existing warnings as on main.
  • yarn test: 59 files, 604 tests pass.
  • Run in node:24.13.0 (ui/.nvmrc) with Corepack and yarn install --immutable, as in the ui-tests CI job. The repository's Playwright suites were not run locally; the live check below drove a browser separately.

Live check

Against a kagent 1.0.0-alpha13 controller, with this branch's UI in Vite live mode (between alpha13 and this branch's base, neither the chat client nor the A2A protos changed). Playwright drove Chromium through a test proxy placed in front of the browser, which cut the streams. The agent used an Ollama model and took 50 to 85 s per answer.

Proxy behaviour Before d3c7100 After
SendStreamingMessage cut at 3 s, each re-attached stream cut 0.2 s after its first frame re-attached 93 times in 47 s, until the task finished gave up after 3 re-attaches, about 11 s after sending, and showed the error; the finished answer appeared on reload
SendStreamingMessage cut at 3 s, each re-attached stream carries only the snapshot and is cut at 12 s not run followed through 4 cuts, showed the full answer, no error
SendStreamingMessage cut at 3 s, re-attached stream passed through not run re-attached once and streamed the answer to the end

A proxy between the browser and the controller can close a stream that has
been quiet for a while, for example while a model reads a long prompt. The
task keeps running on the server and completes, but the UI reported the turn
as failed ("network error").

Once the stream has named its task, a transport failure (Unknown or
Unavailable) now re-attaches to that task with SubscribeToTask instead of
failing the turn. SubscribeToTask is read-only and returns the stored task
when it has already finished, so the message is never sent twice. Errors
the controller decided, a broken stream before any task is known, and a
user abort still end the turn as before. Re-attaching gives up after three
consecutive failures with no frame in between.

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
@github-actions github-actions Bot added the bug Something isn't working label Oct 14, 2026
SubscribeToTask opens with the task as it stands, and every frame reset the
failure count, so a path that broke right after that frame re-attached until
the task finished. Behind a proxy that cut each re-attached stream 0.2 s after
its first frame, one turn re-attached 93 times in 47 s.

The count now resets only on a frame after the snapshot, or when a stream
stayed open for 10 s, long enough to have been cut for being idle. Re-attaches
also wait 0.5 s, 1 s and 2 s. The same turn now gives up after three attempts,
and a turn whose re-attached streams are cut every 12 s while the model is
quiet is still followed to the end.

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
Comment thread ui/src/api/chat/a2aGrpcChatClient.ts Outdated
for (let failures = 0; ; ) {
try {
for await (const frame of source) {
failures = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it wasn't bounded. SubscribeToTask opens with the task snapshot, so a path that broke right after that frame reset the count on every attempt. Behind a test proxy that cut each re-attached stream 0.2 s after its first frame, one turn re-attached 93 times in 47 s, until the task finished.

d3c7100 resets the count only on a frame after the snapshot, or when a stream stayed open for 10 s (long enough to have been cut for being idle), and waits 0.5 s, 1 s and 2 s between attempts. With the same proxy the turn now gives up after three re-attaches. Re-attached streams cut every 12 s while the model is quiet are still followed to the end. The new tests in a2aGrpcChatClient.test.ts cover both cases, and I added the live runs to the description.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 15, 2026
@Charlesthebird

Charlesthebird commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Before/after on a fresh kind cluster. A proxy cuts the chat stream after 12 s of silence while a BYO agent stays quiet for 30 s.

  • Chat turn: the turn re-attaches to its task and shows the answer. On main it fails with "network error" and the answer only appears after a reload.
reel-pr3198.mp4

Screenshots

Before: turn fails when the stream is cut before-stream-cut
Before: answer only after reload before-answer-after-reload
After: turn follows the task and shows the answer after-stream-cut

🤖 written by Claude

@Charlesthebird Charlesthebird left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This automated review is a first pass. A manual review will follow once the blockers are addressed.


Nothing blocks merge. One Medium item is inline: a snapshot artifact can be truncated after a re-attach.

✅ What I checked
  • ✅ Retried codes: connect-web maps a missing trailer and fetch TypeError to Unknown, and 502/503/504 to Unavailable; a2a-go defaults server errors to Internal.
  • ✅ SubscribeToTask returns the stored task when quiescent (interactions.go:124), so a re-attach never resends the message.
  • ✅ input_required and terminal states set settled, so they surface instead of re-attaching.
  • ✅ pause clears its timer and listener on both paths; an abort mid-wait or mid-subscribe reaches rethrowIfAborted.
  • ✅ Snapshot messages dedupe via delivered; snapshot artifacts reuse their artifactId.
  • ✅ New tests use fake timers, including Date.now for the 10 s rule, and pass locally.

🤖 written by Claude

Comment thread ui/src/api/chat/a2aGrpcChatClient.ts Resolved
A reply that starts while the stream is down is first seen in the task
snapshot that opens the re-attached stream. The snapshot showed its
artifact but did not record it, so the next `append` chunk was taken for a
new artifact and replaced "alpha beta" with " gamma" until the closing
chunk arrived. The snapshot now records the artifacts it shows.

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
Snapshot messages were shown once per turn, so after a second re-attach the
snapshot's copy of a reply already on screen was skipped. Chunks sent while
the stream was down were missing until the closing chunk: "alpha beta delta"
instead of "alpha beta gamma delta". Artifacts are now shown again from
every snapshot; other snapshot messages are still shown once.

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 22, 2026
…attach-snapshot-artifacts

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
kagent-dev#3098 changed the artifact map to hold parts rather than text. Seed it
from the snapshot the same way, so the branch builds against main.

Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com>
@Charlesthebird
Charlesthebird added this pull request to the merge queue Oct 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 22, 2026
@Charlesthebird
Charlesthebird added this pull request to the merge queue Oct 22, 2026
Merged via the queue into kagent-dev:main with commit 6716fa2 Oct 22, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] UI marks a chat turn as failed when a proxy idle timeout cuts the response stream, although the task completes

2 participants

⌂ 换网址