Skip to content

Commit c476265

Browse files
fix(chat): keep AI SDK status correct when reconnect races the pre-stream window (#1784) (#1803)
* fix(chat): keep AI SDK status correct when reconnect races the pre-stream window (#1784) A chat turn spends a window between "request accepted" and "first chunk produced" in neither the active-stream nor terminal-replay state: it is queued, debouncing, waiting on waitForMcpConnections, or running async setup inside onChatMessage before a stream object exists. A client that reconnected or re-mounted in that window was answered with cf_agent_stream_resume_none and gave up, so the turn the server went on to stream never drove the client's AI SDK `status` — the UI stayed stuck at "ready" until a full remount. Server (agents/chat): - New shared PreStreamTurns tracker (pre-stream-turns.ts) for accepted-but-not- yet-streamed turns and the connections parked waiting on them, mirroring ContinuationState (pure data + send-through-callback). - New server->client cf_agent_stream_pending frame (protocol + wire-types + golden builder). - ResumeHandshake now parks resume requests that arrive during the pre-stream window and emits STREAM_PENDING ("keep waiting") instead of RESUME_NONE, then flushes parked connections into the normal STREAM_RESUMING handshake on stream start. Continuation affinity is relaxed via an optional isConnectionPresent host hook so a transparent reconnect (whose connection id changed) can resume a continuation whose original owner connection is gone. Client: - ws-chat-transport: handleStreamPending() extends the resume probe from the 5s fast path to a 60s backstop so the probe stays open across the gap. - useAgentChat re-probes the stream on a transparent socket reopen (e.g. a 1006 reconnect that does not remount the component) so status recovers. Hosts: - Wired the begin/park/flush/settle lifecycle into both AIChatAgent and @cloudflare/think. - Skipped turns (supersede / queue generation change) settle WITHOUT releasing parked connections (releaseParked: false), so a client parked during the window survives onto the successor turn instead of being cut loose by a premature RESUME_NONE in the supersede/settle microtask race. Hibernation: PreStreamTurns is in-memory only and is safe because the pre-stream window cannot overlap hibernation — a turn between begin() and stream start is an unresolved message-handler promise that pins the DO in memory, so eviction only happens once a durable stream exists (resumed via ResumableStream) or the turn finished. Documented as an invariant on each _preStream field. Tests: - Unit: PreStreamTurns (incl. skip-path contract), handshake park/pending/flush/ affinity, golden STREAM_PENDING frame. - Transport: STREAM_PENDING keep-waiting timeout extension (incl. tool continuation path). - Integration (workers runtime, real AIChatAgent): onConnect/resume-request park -> pending -> resuming, and parked-client-survives-overlapping-submits. - React hook: transparent reconnect re-probe + STREAM_PENDING keepalive. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ai-chat): guarantee pre-stream turn settles if a pre-turn-body step throws (#1784) AIChatAgent.begin()'d the pre-stream turn before persistMessages, _mergeQueuedUserMessages, and the queued mcp.waitForConnections / _setRequestContext steps. A throw in any of those never reached the settle in chatTurnBody's finally, so the request id stayed stuck in _preStream (hasInFlight() true forever) and every later client was parked on STREAM_PENDING (60s) instead of getting an immediate STREAM_RESUME_NONE — until chat clear or DO eviction. Wrap the whole post-begin() scope in a top-level try/finally that always calls _settlePreStreamTurn, mirroring @cloudflare/think. Idempotent with the existing happy-path settle. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Sunil Pai <18808+threepointone@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c58b401 commit c476265

17 files changed

Lines changed: 1506 additions & 160 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"agents": patch
3+
"@cloudflare/ai-chat": patch
4+
"@cloudflare/think": patch
5+
---
6+
7+
Fix AI SDK `status` getting stuck after a reconnect that races a turn's
8+
pre-stream window (#1784).
9+
10+
A turn is "accepted but pre-stream" while it is queued, debouncing, or awaiting
11+
async setup before its resumable stream starts. A client that connected or sent
12+
a `STREAM_RESUME_REQUEST` in that window was answered with `STREAM_RESUME_NONE`
13+
("nothing to resume"), so its short resume probe resolved `null` and AI SDK
14+
`status` settled on `ready` even though the server went on to stream — leaving
15+
the UI unable to render the in-flight turn until a full remount.
16+
17+
This adds a shared `PreStreamTurns` tracker (`agents/chat`) and a new
18+
server→client `cf_agent_stream_pending` frame:
19+
20+
- The resume handshake now parks resume requests that arrive during the
21+
pre-stream window and emits `STREAM_PENDING` ("keep waiting") instead of
22+
`STREAM_RESUME_NONE`, then flushes parked connections into the normal
23+
`STREAM_RESUMING` handshake once the stream actually starts (and releases them
24+
with `STREAM_RESUME_NONE` if the turn is superseded/cleared before streaming).
25+
- On `STREAM_PENDING` the client transport extends its resume probe from the
26+
5s fast-path to a 60s backstop so the probe stays open across the gap.
27+
- `useAgentChat` re-probes the stream on a transparent socket reopen (e.g. a
28+
1006 reconnect that does not remount the component) so `status` recovers.
29+
- Continuation affinity is relaxed via an optional `isConnectionPresent` host
30+
hook so a transparent reconnect (whose connection id changed) can resume a
31+
continuation whose original owner connection is gone.
32+
33+
Wired into both `AIChatAgent` and `@cloudflare/think`.
34+
35+
The pre-stream tracker is in-memory only; it is hibernation-safe because a turn
36+
in its pre-stream window is an unresolved message-handler promise that pins the
37+
Durable Object in memory, so eviction only happens once a stream is durably
38+
recorded (and resumes via `ResumableStream`) or the turn has finished. Skipped
39+
turns (supersede/generation change) settle without releasing parked
40+
connections, so a client parked during the window survives onto the successor
41+
turn instead of being cut loose by a premature `STREAM_RESUME_NONE`.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { PreStreamTurns } from "../pre-stream-turns";
3+
import type { ChatConnection } from "../connection";
4+
5+
type SentFrame = Record<string, unknown>;
6+
7+
function makeConnection(
8+
id: string,
9+
sink?: SentFrame[]
10+
): ChatConnection & { send: ReturnType<typeof vi.fn> } {
11+
return {
12+
id,
13+
send: vi.fn((message: string) => {
14+
sink?.push(JSON.parse(message) as SentFrame);
15+
})
16+
};
17+
}
18+
19+
describe("PreStreamTurns (#1784)", () => {
20+
it("starts idle: nothing in flight, park is a no-op that sends no frame", () => {
21+
const pre = new PreStreamTurns();
22+
expect(pre.hasInFlight()).toBe(false);
23+
24+
const conn = makeConnection("c1");
25+
expect(pre.park(conn)).toBe(false);
26+
expect(conn.send).not.toHaveBeenCalled();
27+
expect(pre.awaitingConnections.size).toBe(0);
28+
});
29+
30+
it("begin marks in flight and exposes the latest request id", () => {
31+
const pre = new PreStreamTurns();
32+
pre.begin("req-1");
33+
expect(pre.hasInFlight()).toBe(true);
34+
expect(pre.latestRequestId).toBe("req-1");
35+
});
36+
37+
it("park enrolls a connection and sends STREAM_PENDING with the request id", () => {
38+
const frames: SentFrame[] = [];
39+
const pre = new PreStreamTurns();
40+
pre.begin("req-1");
41+
42+
const conn = makeConnection("c1", frames);
43+
expect(pre.park(conn)).toBe(true);
44+
expect(pre.awaitingConnections.get("c1")).toBe(conn);
45+
expect(frames).toEqual([{ type: "cf_agent_stream_pending", id: "req-1" }]);
46+
});
47+
48+
it("flushOnStreamStart notifies each parked connection then clears the map (keeps accepted)", () => {
49+
const pre = new PreStreamTurns();
50+
pre.begin("req-1");
51+
const c1 = makeConnection("c1");
52+
const c2 = makeConnection("c2");
53+
pre.park(c1);
54+
pre.park(c2);
55+
56+
const notified: string[] = [];
57+
pre.flushOnStreamStart((c) => notified.push(c.id));
58+
59+
expect(notified.sort()).toEqual(["c1", "c2"]);
60+
expect(pre.awaitingConnections.size).toBe(0);
61+
// The turn is still running until settle() — accepted set untouched.
62+
expect(pre.hasInFlight()).toBe(true);
63+
});
64+
65+
it("releaseAwaiting sends STREAM_RESUME_NONE to each parked connection then clears", () => {
66+
const frames: SentFrame[] = [];
67+
const pre = new PreStreamTurns();
68+
pre.begin("req-1");
69+
const c1 = makeConnection("c1", frames);
70+
pre.park(c1);
71+
frames.length = 0;
72+
73+
pre.releaseAwaiting();
74+
75+
expect(frames).toEqual([{ type: "cf_agent_stream_resume_none" }]);
76+
expect(pre.awaitingConnections.size).toBe(0);
77+
});
78+
79+
it("settle returns true only when the last accepted turn settles", () => {
80+
const pre = new PreStreamTurns();
81+
pre.begin("a");
82+
pre.begin("b");
83+
84+
expect(pre.settle("a")).toBe(false);
85+
expect(pre.hasInFlight()).toBe(true);
86+
expect(pre.settle("b")).toBe(true);
87+
expect(pre.hasInFlight()).toBe(false);
88+
expect(pre.latestRequestId).toBeNull();
89+
});
90+
91+
it("settling an unknown id is safe and reports idle when nothing else is in flight", () => {
92+
const pre = new PreStreamTurns();
93+
expect(pre.settle("never-began")).toBe(true);
94+
});
95+
96+
it("a connection parked during the gap between queued turns survives the first turn's settle", () => {
97+
// A begins, streams, settles while B is still queued (begin'd). A reconnect
98+
// that parked must NOT be released until B also settles with no stream.
99+
const pre = new PreStreamTurns();
100+
pre.begin("a");
101+
pre.begin("b");
102+
const conn = makeConnection("c1");
103+
pre.park(conn);
104+
105+
// A finishes first: not idle (B still in flight) → caller would NOT release.
106+
expect(pre.settle("a")).toBe(false);
107+
expect(pre.awaitingConnections.get("c1")).toBe(conn);
108+
109+
// B finishes: now idle.
110+
expect(pre.settle("b")).toBe(true);
111+
});
112+
113+
it("models the skip-path contract: settle WITHOUT releaseAwaiting keeps a parked client for the successor (#1784)", () => {
114+
// Mirrors the host `_completeSkippedRequest` flow: a superseded turn settles
115+
// out of the accepted set but must NOT release parked connections, so a
116+
// client parked during the pre-stream window survives onto the turn that
117+
// actually streams — even if the set momentarily empties before the
118+
// successor's `begin()` runs (the supersede/settle microtask race).
119+
const pre = new PreStreamTurns();
120+
pre.begin("superseded");
121+
const conn = makeConnection("c1");
122+
pre.park(conn);
123+
124+
// The superseded turn settles. The host does NOT call releaseAwaiting here.
125+
expect(pre.settle("superseded")).toBe(true); // set momentarily empty
126+
expect(pre.awaitingConnections.get("c1")).toBe(conn); // still parked
127+
128+
// The successor turn begins and starts streaming → the parked client is
129+
// flushed into the normal resume handshake rather than stranded.
130+
pre.begin("successor");
131+
const notified: string[] = [];
132+
pre.flushOnStreamStart((c) => notified.push(c.id));
133+
expect(notified).toEqual(["c1"]);
134+
expect(pre.awaitingConnections.size).toBe(0);
135+
});
136+
137+
it("release drops a single connection without touching the rest", () => {
138+
const pre = new PreStreamTurns();
139+
pre.begin("req-1");
140+
const c1 = makeConnection("c1");
141+
const c2 = makeConnection("c2");
142+
pre.park(c1);
143+
pre.park(c2);
144+
145+
pre.release("c1");
146+
147+
expect(pre.awaitingConnections.has("c1")).toBe(false);
148+
expect(pre.awaitingConnections.get("c2")).toBe(c2);
149+
});
150+
151+
it("park without a known request id omits the id from the keep-waiting frame", () => {
152+
const frames: SentFrame[] = [];
153+
const pre = new PreStreamTurns();
154+
// settle the only id so latestRequestId is null but force in-flight via a
155+
// second begin with an empty-ish id path: emulate by beginning then parking.
156+
pre.begin("");
157+
const conn = makeConnection("c1", frames);
158+
pre.park(conn);
159+
expect(frames).toEqual([{ type: "cf_agent_stream_pending" }]);
160+
});
161+
162+
it("reset drops all state and sends no frames", () => {
163+
const pre = new PreStreamTurns();
164+
pre.begin("req-1");
165+
const c1 = makeConnection("c1");
166+
pre.park(c1);
167+
c1.send.mockClear();
168+
169+
pre.reset();
170+
171+
expect(pre.hasInFlight()).toBe(false);
172+
expect(pre.awaitingConnections.size).toBe(0);
173+
expect(pre.latestRequestId).toBeNull();
174+
expect(c1.send).not.toHaveBeenCalled();
175+
});
176+
});

packages/agents/src/chat/__tests__/resume-handshake-frames.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
HANDSHAKE_INVARIANTS,
55
recoveringFrame,
66
replayDoneFrame,
7+
streamPendingFrame,
78
streamResumeNoneFrame,
89
streamResumingFrame,
910
terminalErrorFrame
@@ -54,6 +55,16 @@ describe("resume-handshake golden frames", () => {
5455
expect("replay" in frame).toBe(false);
5556
});
5657

58+
it("STREAM_PENDING includes the id only when present (#1784)", () => {
59+
expect(streamPendingFrame("req-1")).toEqual({
60+
type: "cf_agent_stream_pending",
61+
id: "req-1"
62+
});
63+
const noId = streamPendingFrame();
64+
expect(noId).toEqual({ type: "cf_agent_stream_pending" });
65+
expect("id" in noId).toBe(false);
66+
});
67+
5768
it("recovering frame includes the id only when present", () => {
5869
expect(recoveringFrame("req-1")).toEqual({
5970
type: "cf_agent_chat_recovering",
@@ -82,6 +93,9 @@ describe("resume-handshake golden frames", () => {
8293
CHAT_MESSAGE_TYPES.USE_CHAT_RESPONSE
8394
);
8495
expect(recoveringFrame("x").type).toBe(CHAT_MESSAGE_TYPES.CHAT_RECOVERING);
96+
expect(streamPendingFrame("x").type).toBe(
97+
CHAT_MESSAGE_TYPES.STREAM_PENDING
98+
);
8599
});
86100

87101
it("a host with a distinct use-chat-response constant is honored", () => {

packages/agents/src/chat/__tests__/resume-handshake-frames.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ export function streamResumeNoneFrame() {
5454
};
5555
}
5656

57+
/**
58+
* Server -> client `STREAM_PENDING` keep-waiting frame (#1784): a turn is
59+
* accepted but its resumable stream has not started yet. Carries `id` only when
60+
* the accepted request id is known. The client cancels its short resume probe
61+
* timeout and waits for a later `STREAM_RESUMING` or `STREAM_RESUME_NONE`.
62+
* Emitted by `PreStreamTurns.park`.
63+
*/
64+
export function streamPendingFrame(requestId?: string) {
65+
return {
66+
type: CHAT_MESSAGE_TYPES.STREAM_PENDING,
67+
...(requestId ? { id: requestId } : {})
68+
};
69+
}
70+
5771
/**
5872
* Server -> client terminal replay-done frame: the ACK fallback when no live or
5973
* completed chunks remain to replay, closing the resumed stream cleanly.

0 commit comments

Comments
 (0)