Skip to content

reconnectToStream sends RESUME_REQUEST before WebSocket is open #1000

Closed

Description

@alexanderjacobsen

Problem

WebSocketChatTransport.reconnectToStream() sends CF_AGENT_STREAM_RESUME_REQUEST after a fixed 100ms setTimeout, but after a page refresh the WebSocket hasn't opened yet at that point. The agent.send() throws, the try/catch swallows it, and after the 3s timeout the method returns null → status stays "ready".

Result: After refreshing during an active stream, the text eventually appears (via onAgentMessage), but useChat status never transitions to "streaming" — no abort button, no loading indicator.

Where

WebSocketChatTransport.reconnectToStream() in packages/ai-chat/src/react.ts:

const requestDelay = setTimeout(() => {
  if (!resolved) try {
    agent.send(JSON.stringify({ type: MessageType.CF_AGENT_STREAM_RESUME_REQUEST }));
  } catch {}
}, 100);

The 100ms delay was added to avoid a race with the server's automatic CF_AGENT_STREAM_RESUMING in onConnect, but it doesn't account for WebSocket connection time — which is almost always longer than 100ms on a fresh page load.

Suggested fix

Wait for the WebSocket to be open before sending:

function sendWhenOpen(agent, message) {
  if (agent.readyState === WebSocket.OPEN) {
    agent.send(message);
  } else {
    agent.addEventListener("open", () => agent.send(message), { once: true });
  }
}

Related

Follow-up to the fix in PR #999 (which fixed the ReadableStream return from reconnectToStream). The transport pipeline works correctly once the request actually reaches the server — the only issue is the send timing.

See also #896 (comment): #896 (comment)

Activity

  1. alexanderjacobsen commented on Feb 26, 2026

    @alexanderjacobsen
    ContributorAuthor

    Closing — my analysis was wrong. PartySocket queues messages when the WebSocket isn't open yet, so the RESUME_REQUEST does get delivered. The root cause of the status issue is something else. Sorry for the noise.

  2. alexanderjacobsen commented on Feb 26, 2026

    @alexanderjacobsen
    ContributorAuthor

    Reopening — confirmed with debug logging. The original theory was correct.

    On page refresh, reconnectToStream is called while the WebSocket readyState is 3 (CLOSED). PartySocket queues the outgoing RESUME_REQUEST, but the onDetect event listener is registered on a not-yet-connected socket. By the time PartySocket reconnects and the server responds with STREAM_RESUMING, the 3s timeout has already fired and removed the onDetect listener. Nobody receives the response.

    Console output:

    [RESUME] reconnectToStream called, agent readyState: 3
    [RESUME] sending RESUME_REQUEST (100ms backup)
    [RESUME] 3s timeout — no STREAM_RESUMING received, resolving null
    

    PartySocket queues outgoing send() calls, but incoming addEventListener("message", ...) listeners registered before the socket opens do NOT receive messages that arrive after reconnection — the listener is on the old internal WebSocket instance.

    The fix should either wait for the WebSocket to be open before starting the detection flow, or ensure the listener survives reconnection.

  3. threepointone commented on Feb 26, 2026

    @threepointone
    Contributor

    good catch. Ill update the PR

  4. threepointone commented on Feb 26, 2026

    @threepointone
    Contributor

    btw thanks so much for the detailed reports and your patience. it's a tricky thing to solve, and it's great having info like this to work on.

  5. threepointone commented on Feb 26, 2026

    @threepointone
    Contributor

    ok try this one again npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@999

  6. alexanderjacobsen commented on Feb 27, 2026

    @alexanderjacobsen
    ContributorAuthor

    The fix looks good in the source but the pkg.pr.new tarball still has the old built output. Did the preview build not re-trigger on the force push?

  7. threepointone commented on Feb 27, 2026

    @threepointone
    Contributor

    hmm probably not, they've been having issues

    I'll generate a fresh build in a bit, afk

  8. threepointone commented on Feb 27, 2026

    @threepointone
    Contributor

    this build should point at the latest hash npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@81acb00

  9. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    @threepointone Tested the new build (@81acb00). The transport listener never receives STREAM_RESUMING — onAgentMessage handles it first via the fallback path, which doesn't set status to streaming.

    Debug logs after refreshing while the model is thinking:

    [RESUME] reconnectToStream called, readyState: 3
    [RESUME] sent RESUME_REQUEST
    [RESUME] reconnectToStream called, readyState: 3
    [RESUME] sent RESUME_REQUEST
    [RESUME] onAgentMessage got STREAM_RESUMING, id: p12iwdDZ localIds: Array(0)
    [RESUME] onAgentMessage handling STREAM_RESUMING via fallback path
    

    Two issues:

    1. reconnectToStream is called twice (React strict mode double-effect), both with readyState 3
    2. localRequestIdsRef is empty when STREAM_RESUMING arrives, so onAgentMessage doesn't skip it. The transport's addEventListener("message", onMessage) never fires.
  10. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    Thanks for reporting again. Could you try npm i https://pkg.pr.new/@cloudflare/ai-chat@5ad1325?

  11. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    Actually, try this one npm i https://pkg.pr.new/@cloudflare/ai-chat@b466803

  12. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    @threepointone Tested @b466803. The handleStreamResuming / _resumeResolver approach is correct, but there's a transport instance mismatch that prevents it from working.

    The problem: useAgentChat creates customTransport via useMemo, but the useChat ChatStore (backed by useRef) captures the transport at construction time. In React Strict Mode (dev), useMemo produces new instances across re-renders, so the ChatStore holds transport #1 while the onAgentMessage effect closure captures transport #3. reconnectToStream sets _resumeResolver on #1, but handleStreamResuming checks #3 — which never had a resolver.

    Debug logs after refresh-during-stream:

    WebSocketChatTransport created, id: 1
    WebSocketChatTransport created, id: 2
    reconnectToStream called on transport: 1    ← ChatStore uses this one
    sent STREAM_RESUME_REQUEST
    reconnectToStream called on transport: 1
    sent STREAM_RESUME_REQUEST
    WebSocketChatTransport created, id: 3
    WebSocketChatTransport created, id: 4
    onAgentMessage got STREAM_RESUMING, id: omZXz_PZ
    handleStreamResuming called on transport: 3 has resolver: false    ← but onAgentMessage checks this one
    transport did NOT handle, using fallback path
    reconnectToStream 5s timeout fired
    reconnectToStream done(), value: null
    

    The fallback path in onAgentMessage does resume the stream (messages come through), but it bypasses useChat's makeRequest flow entirely, so status stays "ready" instead of "streaming".

    Fix: The transport needs to be a stable reference. Either:

    1. Use useRef instead of useMemo for customTransport so the same instance is shared
    2. Or have onAgentMessage access the transport through the ChatStore ref instead of closing over it
    // Option 1: stable ref
    const customTransportRef = useRef<WebSocketChatTransport>(null);
    if (!customTransportRef.current) {
      customTransportRef.current = new WebSocketChatTransport({ ... });
    }
    const customTransport = customTransportRef.current;
  13. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    Here's another try npm i https://pkg.pr.new/@cloudflare/ai-chat@60c58bf

  14. 2 remaining items

  15. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    Ignore my Strict Mode comments — that wasn't it. Tested in production mode with logging. After refreshing during streaming:

    Transport created, tid: 1
    reconnectToStream on tid: 1 readyState: 3    ← WS closed, resume request silently fails
    Transport created, tid: 2                     ← _pk changed, new transport
    STREAM_RESUMING received, tid: 2 resolver: false  ← resolver stranded on tid:1
    fallback path used
    done() on tid: 1 value: null                  ← 5s timeout, status → "ready"
    Transport created, tid: 3                     ← _pk changed again
    

    _pk changes 3 times during reconnection, recreating the transport each time. The resolver is always on a previous transport that onAgentMessage no longer references. Is this expected for _pk?

  16. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    I think the fix is:

    1. Don't recreate the transport on _pk changes — create once with useRef, update this.agent in-place each render
    2. Use customTransportRef.current in onAgentMessage instead of the closure-captured customTransport (and remove it from the effect deps)
    3. Wait for the WebSocket to be open before sending STREAM_RESUME_REQUEST (readyState was 3 when reconnectToStream fired)

    With (1) and (2) applied locally, the resolver is found and the stream handshake completes.

  17. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    what user facing bug are you seeing?

  18. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    When refreshing the page while the model is thinking/streaming, the "Thinking..." status doesn't persist. The status goes back to "ready" so the user can send a new message even though the model is still working. The response does eventually come through via the fallback path, but useChat's status is wrong.

  19. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    I believe our PR fixes that exact scenario, does it not for you?

  20. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    Like, it's fine if it shows ready while it connects, there's no real way to solve that until the websocket connects (unless you're storing some client side state)

  21. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    The issue is that it stays "ready" permanently, even after the WebSocket reconnects and the server sends STREAM_RESUMING. The transport that receives the STREAM_RESUMING message is a different instance than the one holding the resolver (because _pk changed and a new transport was created), so the resolver is never called and the status never transitions to "streaming".

  22. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    can you make a repro? I need to see if you're using an async query or something. you can use the agent starter if that helps https://github.com/cloudflare/agents-starter

  23. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    I'm putting up a fix for async queries, but I'd still love a repro if possible

  24. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    try npm i https://pkg.pr.new/@cloudflare/ai-chat@807365f I bet it's all solved now

  25. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    @807365f didn't fix it — chat history disappears after refresh now (initialMessages comes back empty). Will put together a minimal repro when I have time.

  26. alexanderjacobsen commented on Feb 28, 2026

    @alexanderjacobsen
    ContributorAuthor

    Hey @threepointone — turns out this was on my end. I had set getInitialMessages: null in my useAgentChat call and was fetching messages manually as a workaround a while back, and forgot about it. That bypassed the SDK's Suspense-based message loading, which is what enables stream resumption.

    Once I removed that and let the SDK use its default getInitialMessages, resume works as expected. Thanks for looking into it and for the singleton transport fix!

  27. threepointone commented on Feb 28, 2026

    @threepointone
    Contributor

    Fantastic glad we got here! I'll merge the pr now. Thanks for digging deep with me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions