Repository navigation
Facet broadcast can reorder consecutive WebSocket frames #2177
Description
Describe the bug
Agent.broadcast() can reorder consecutive WebSocket frames when the Agent is a sub-agent facet and no forwarded invocation bridge is active.
The facet branch calls _cf_broadcastToParentSubAgent(msg, without) without returning or awaiting its promise. Each call can therefore start an independent asynchronous route through the root Agent. Two broadcasts made in order can reach the browser in the opposite order.
This affects streamed chat output. In a production Think sub-agent, a terminal tool-card frame could arrive before the preceding assistant text frame. The client then rendered incomplete output or entered stream recovery even though the exact sub-agent WebSocket stayed open.
PR #2090 added _cf_routeSubAgentConnectionOperation(), which queues send, setState, and close operations per virtual connection. The current facet broadcast() path bypasses that ordered queue.
To Reproduce
- Connect a browser WebSocket directly to a chat-capable sub-agent.
- Let the forwarded invocation frame finish so
_cf_activeSubAgentBridge()is not active. - From the facet, call
this.broadcast(frame1)and thenthis.broadcast(frame2)in the same turn. - Add a delay to the first root broadcast route, or repeat under normal RPC latency.
- Observe that
frame2can reach the browser beforeframe1.
The relevant current path is equivalent to:
broadcast(msg, without) {
if (this._isFacet) {
this._cf_broadcastToParentSubAgent(msg, without);
return;
}
// top-level path
}The downstream fix that resolved the live reproduction routes facet broadcasts through each exact virtual connection:
if (this._isFacet) {
for (const connection of this.getConnections()) {
if (without?.includes(connection.id)) continue;
connection.send(msg);
}
return;
}Virtual connection.send() already uses _cf_routeSubAgentConnectionOperation(). Its per-connection promise tail preserves call order. This also keeps the existing without behavior.
A regression test should verify that:
- facet
broadcast()does not use the aggregate parent broadcast path; - two consecutive broadcasts use each exact virtual connection in call order;
withoutstill excludes the selected connection;- the top-level Agent path does not change.
A downstream patch and regression test are visible in science-of-people/prosocial-v2#664.
Expected behavior
For one WebSocket connection, calls to this.broadcast() must arrive in call order for top-level Agents and sub-agent facets. A later terminal frame must not overtake earlier streamed content.
Screenshots
Not applicable. WebSocket traces and persisted message order were used to diagnose the failure.
Version:
agents@0.20.1- package commit
cc50e404b23ee480df7c783c5fa34c4156950889 @cloudflare/think@0.15.1
Additional context
Related upstream work:
- Bug + fix: cross-DO I/O error breaks Facet sub-agents #1329 identified earlier facet broadcast problems, but it addressed cross-Durable-Object I/O and made facet broadcasts no-op at that time.
- Client tool continuation stays pending for 60 seconds after completion #2055 reported delayed sub-agent WebSocket operations.
- Fix delayed sub-agent WebSocket operations #2090 added the ordered per-connection operation path that exposes the narrow fix for this race.
This is not a prompt, tool, model, provider, client reconnect, or second-startup-path problem. It is a server-side ordering gap between facet broadcast() and the ordered virtual connection path.
Activity
scott-edwards commentedon Aug 28, 2026
Closing this report. I checked the published artifact after filing and confirmed that agents@0.22.0 already contains PR #2090, including the ordered facet broadcast barrier and the Think regression that keeps STREAM_RESUMING before a later terminal broadcast. This report was based on our older preview pin plus a partial downstream patch, not the current published SDK. Sorry for the incorrect report.
O4epegb commentedon Sep 3, 2026
Are you sure it was fixed? I'm encountering similar issues on 0.22.0 still