Repository navigation
fix(chat): settle turns correctly across replay, resume, and observers - #2383
Conversation
Wire protocol (agents, ai-chat): - Record a stream's close outcome in its metadata and echo it on replayed and bare-done terminals; orphaned streams report "aborted" and a recovery-scheduled close reports "recovering". Outcomes and origin ids survive cutover/reclaim deleting the row. - A resume ACK that lands while ai-chat holds the terminal frames replays the finished stream without an early bare done. - The "No response" done reaches every connection, including the origin, with an outcome. useAgentChat: - Observers settle on a done for a request other than the observed one. - A recovering done keeps isRecovering and holds onToolCall until the successor turn. - onTurnEnd for the tab's own request fires after status settles, so sendMessage works inside it. - With resume:false, probe on reconnect so a held onToolCall is released. - Live chunks skip the merge once divergence is detected. - Cap remembered turn errors and clear them in clearHistory. Co-authored-by: Cursor <cursoragent@cursor.com>
…play Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: c86b5cf The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟡 agents import sizes: 1 entry point grew
Changed exports (1)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
| if (idleProbeNeeded) { | ||
| idleProbeNeeded = false; | ||
| idleProbeId = nanoid(); | ||
| agentRef.current.send( | ||
| JSON.stringify({ | ||
| type: MessageType.CF_AGENT_STREAM_RESUME_REQUEST, | ||
| probeId: idleProbeId | ||
| }) | ||
| ); |
There was a problem hiding this comment.
🔴 Active resume offers release tools early
When resume: false receives only a probe-correlated offer for an active stream, setUnresolvedObservedRequestId clears the old hold. The server excludes unacknowledged connections from live chunks, so onToolCall runs before that stream ends.
Learn more
A reconnect with resume: false sends an idle probe when a prior turn's terminal was missed. An active stream can answer that probe with STREAM_RESUMING carrying a probeId; notifyStreamResuming then puts this connection in pendingResumeConnections, excluding it from live frames until an ACK. The client intentionally does not ACK with resume: false. This branch holds only offers without probeId, while the preceding state update clears the previous hold. An assistant tool part in the current transcript can therefore be treated as client-owned before the server finishes it.
Example: Tab A misses request A's done, then reconnects while request B runs a server-side tool. Its proactive offer is missed; its probe receives {id: "B", probeId: "probe"}. Request A's hold clears, B gets no hold, and onToolCall fires for B's unfinished tool before B's done arrives.
Recommended fix: Distinguish active stream offers from terminal replay offers in the handshake, or arrange for resume: false to settle a probe offer without exposing tool calls until the offered turn is known to have ended. Cover the probe-correlated active-offer path with a test.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Partly valid — fixed in c86b5cf. Both hosts already enroll every reconnecting socket as pending-resume in onConnect, and the list is normally cleared before done, so the probe itself doesn't strand it. Two real bugs underneath: Think's chat() RPC path never cleared pending-resume connections (now cleared on finish/complete/error, like ai-chat), and with resume: false an offer for a newer request never released a held older one (now released). Didn't ACK the offer, since resume: false is documented as no automatic resumption.
…olCall gates - ResumableStream replays the request's last closed stream (finished, recovering, or errored) to a resume ACK that lands while ai-chat holds the terminal frames, instead of only the pending-cutover stream. - useAgentChat tracks the recovering request id: replays and the recovering request's own frames no longer release the hold; live frames for a later request, recovering:false, or an idle reconnect probe with no recovery reported on connect do. - With resume:false the reconnect probe also runs for a recovering hold, and an offer for another request releases a stale held turn; a proactive offer holds tool calls until that stream's terminal frame. - Think clears pending resume connections whenever a chat stream ends, so a connection that never ACKs still gets the terminal frame on chat() RPC turns. Co-authored-by: Cursor <cursoragent@cursor.com>
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
Brings react-agui.tsx level with the legacy hook in agents/chat/react, which received these through the upstream merge: - cloudflare#2344 the server snapshot heals a diverged observed assistant - cloudflare#2348 an observer skips replayed continuation frames it already applied - cloudflare#2361 onToolCall fires only after the stream ends - cloudflare#2378 onTurnEnd fires once per ended request - cloudflare#2383 turns settle across replay, resume and observers (held and recovering requests, the resume:false idle probe) - cloudflare#2496 streaming protection is released when the socket closes - cloudflare#2394 follow the socket when the agent name changes - cloudflare#2403 the tool-result cleanup no longer dispatches per chunk - cloudflare#2039 keep an optimistic send the reconnect transcript omits The last three had no failing test: their upstream tests are ported into react-tests (the address-change one against a fake of useAgent's pending socket, since this package has no test worker) and failed before the fix. restoreBufferedSends, MAX_REMEMBERED_ENDED_TURNS and isSocketAddressPending are exported from agents/chat/react so the two hooks share them.
…ge ids and per-event seq Ports the server half of upstream cloudflare#2334, cloudflare#2365, cloudflare#2378, cloudflare#2383 and cloudflare#2348 into AGUIChatAgent. An in-band RUN_ERROR now marks its stream errored when the body ends (a settled stream cannot be re-marked, which had broken cloudflare#1575's errored replay) and rides an error frame the client transport still delivers as an event.
Follow-ups from a review of recently landed chat protocol PRs (#2378, #2365, #2361, #2348, #2344, #2334, #2023, #1838). Each fix has a regression test that fails on
main.Wire protocol (agents, ai-chat, think)
outcome. A stream's close outcome is recorded in its metadata and echoed on replayed and bare-done terminals. Orphaned streams reportaborted, and a close that schedules recovery reportsrecovering(ai-chat and Think). Outcomes and origin ids survive cutover/reclaim deleting the row (last 32 kept in memory).done; the realdonefollows the persisted transcript (regression of fix(ai-chat): send the done frame after the transcript is persisted #2334).donenow reaches every connection, including the originating tab, withoutcome: "completed". Previously the sending tab stayedsubmittedforever.useAgentChat
donefor a request they aren't observing. Previously askippeddone for another request appended an empty assistant message and dropped observation of the real stream.recoveringdone keepsisRecoveringand holdsonToolCalluntil the successor turn, so server tools stuck atinput-availablearen't handed to the client (useAgentChat: onToolCall fires for server-side tools, and answering one sends the server a false failure verdict #2195 class).onTurnEndfor the tab's own request fires afterstatussettles, sosendMessageinside it works.resume: false, the client probes on reconnect so a heldonToolCallis released.clearHistory.Tests
resumable-stream-settlement,resume-handshake,broadcast-stateterminal-frame-order, a new "useAgentChat terminal frames" block inuse-agent-chat.test.tsxorigin-message-idsasserts the storedrecoveringoutcomeVerified on a combined branch with the other review follow-ups:
pnpm run check, agents chat/workers/react, ai-chat, and Think suites all pass.Likely conflicts with the stream-recovery review PR (ai-chat
index.tsand Thinkthink.tsrecovery paths); whichever lands second needs a rebase.