Repository navigation
fix(ui): keep following a chat turn when its stream breaks - #3198
Conversation
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>
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>
| for (let failures = 0; ; ) { | ||
| try { | ||
| for await (const frame of source) { | ||
| failures = 0; |
There was a problem hiding this comment.
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.
|
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.
reel-pr3198.mp4Screenshots🤖 written by Claude |
Charlesthebird
left a comment
There was a problem hiding this comment.
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
TypeErrortoUnknown, and 502/503/504 toUnavailable; a2a-go defaults server errors toInternal. - ✅
SubscribeToTaskreturns the stored task when quiescent (interactions.go:124), so a re-attach never resends the message. - ✅
input_requiredand terminal states setsettled, so they surface instead of re-attaching. - ✅
pauseclears its timer and listener on both paths; an abort mid-wait or mid-subscribe reachesrethrowIfAborted. - ✅ Snapshot messages dedupe via
delivered; snapshot artifacts reuse theirartifactId. - ✅ New tests use fake timers, including
Date.nowfor the 10 s rule, and pass locally.
🤖 written by Claude
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>
…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>



Summary
Fixes #3197
When the chat stream (
A2AService/SendStreamingMessageover 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
Canceledafter 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:
statusUpdatecarriestaskId), a transport failure re-attaches withSubscribeToTask. 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.Unknown(a barenetwork error, or a body that ends without its trailer) andUnavailable(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.The change is confined to
A2AGrpcChatClient.send. Atasksnapshot returned by the subscription goes through the existingtaskbranch, which now records the artifacts it shows, so the nextappendchunk continues them, and shows artifacts again from every snapshot, so a later snapshot fills in chunks sent while the stream was down.Testing
ui/src/api/chat/a2aGrpcChatClient.test.ts:FailedPrecondition) still surfaces and does not re-attachappendchunk instead of being replaced by ityarn typecheck: passes.yarn lint: 0 errors; the same 6 existing warnings as onmain.yarn test: 59 files, 604 tests pass.node:24.13.0(ui/.nvmrc) with Corepack andyarn install --immutable, as in theui-testsCI 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.
SendStreamingMessagecut at 3 s, each re-attached stream cut 0.2 s after its first frameSendStreamingMessagecut at 3 s, each re-attached stream carries only the snapshot and is cut at 12 sSendStreamingMessagecut at 3 s, re-attached stream passed through