Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-observer-error-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agents": patch
"@cloudflare/ai-chat": patch
"@cloudflare/think": patch
---

Treat `useAgentChat` observer error frames as terminal responses.

Plain-text error bodies are no longer parsed as stream chunks or merged into an empty assistant message. Error frames now clear observer streaming, replay, recovery, and tool-continuation state even when they omit `done`, matching the transport-owned stream behavior.
29 changes: 27 additions & 2 deletions packages/agents/src/chat/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2139,10 +2139,10 @@ export function useAgentChat<
}
}

if (data.done || data.replayComplete) {
if (data.done || data.error || data.replayComplete) {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
}
if (data.done) {
if (data.done || data.error) {
if (
streamStateRef.current.status === "observing" &&
streamStateRef.current.streamId === data.id
Expand Down Expand Up @@ -2170,6 +2170,31 @@ export function useAgentChat<
) {
return;
}
if (data.error) {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
customTransport.handleServerTurnCompleted(data.id);
fallbackAckedResumeRequestIdsRef.current.delete(data.id);
setIsRecovering(false);

if (
streamStateRef.current.status === "idle" ||
streamStateRef.current.streamId === data.id
) {
const result = broadcastTransition(streamStateRef.current, {
type: "clear"
});
streamStateRef.current = result.state;
setIsServerStreaming(result.isStreaming);
}
if (observedToolContinuationRequestIdRef.current === data.id) {
resetToolContinuation();
}
break;
}
Comment on lines +2173 to +2193

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Partial assistant answer disappears when a reconnecting tab replays a turn that ended in an error

The partially generated answer that was replayed to a reconnecting tab is thrown away (break in the new error branch at packages/agents/src/chat/react.tsx:2173-2193) before it is written into the chat list, so the user sees an empty turn instead of the text the assistant had produced before failing.

Impact: After reconnecting to a turn that errored mid-generation, the text already produced is silently lost from the conversation instead of being shown alongside the error.

How the replay-then-terminal-error sequence loses the accumulated content

When a client resumes a turn that ended in an error, ResumeHandshake._replayTerminalOnAck (packages/agents/src/chat/resume-handshake.ts:293-322) first replays the stored chunks as replay: true, done: false frames and then sends a terminal { done: true, error: true, body: <diagnostic text> } frame.

In the observer (non transport-owned) path, replayed chunks are accumulated but NOT merged into messages: transition only produces a messagesUpdate for non-replay chunks, replayComplete, or done (packages/agents/src/chat/broadcast-state.ts:130-146). Before this PR, the terminal frame fell through to the response transition with done: true, which ran accumulator.mergeInto(prev) and surfaced the partial text.

With the new short-circuit at packages/agents/src/chat/react.tsx:2173, the terminal error frame instead issues a clear transition (which returns { status: "idle" } with no messagesUpdate) and breaks, so the accumulated replay content is discarded. Consider merging the accumulator (e.g. dispatching a response event with done: true and no chunkData, or explicitly applying accumulator.mergeInto) before clearing observer state.

Prompt for agents
In packages/agents/src/chat/react.tsx, the new observer-side terminal-error short-circuit (around line 2173) clears broadcast stream state and returns without ever merging the accumulated stream content into the message list. For resumed turns, the server (ResumeHandshake._replayTerminalOnAck in packages/agents/src/chat/resume-handshake.ts) replays the errored turn's stored chunks as replay:true/done:false frames and only then sends the terminal error frame. Because the broadcast state machine (packages/agents/src/chat/broadcast-state.ts) only emits a messagesUpdate for non-replay chunks, replayComplete, or done, the replayed partial assistant content is only flushed by the terminal frame. The new short-circuit removes that flush, so the partial answer is lost from the UI. Fix by flushing the observing accumulator into messages (setMessages with accumulator.mergeInto, or by routing through a response transition with done:true and no chunkData) when the observed stream id matches, before clearing observer state — while still avoiding parsing the human-readable error body as a chunk.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Error bodies are human-readable diagnostics, not UI message
// chunks. The transport-owned path short-circuits them before JSON
// parsing; observers must do the same.
if (data.body?.trim()) {
try {
chunkData = JSON.parse(data.body);
Expand Down
63 changes: 63 additions & 0 deletions packages/agents/src/react-tests/resume-overlap-race.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,79 @@ function requireChat(chat: AgentChatResult | null): AgentChatResult {

describe("reconnect-driven stream resume", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
errorSpy.mockRestore();
warnSpy.mockRestore();
cleanup();
});

it.each([
{ label: "with done", done: true },
{ label: "without done", done: undefined }
])(
"treats observed error frames as terminal $label (#1898)",
async ({ done }) => {
const { agent, target, sentMessages } = createFakeAgent({
name: "observer-error",
url: "ws://localhost:3000/agents/chat/observer-error?_pk=abc"
});

function TestComponent() {
const chat = useAgentChat({
agent,
getInitialMessages: null,
messages: [] as UIMessage[]
});
return (
<div>
<div data-testid="message-count">{chat.messages.length}</div>
<div data-testid="streaming">{String(chat.isServerStreaming)}</div>
</div>
);
}

const { container } = await render(<TestComponent />);
await vi.waitFor(() => {
expect(countType(sentMessages, RESUME_REQUEST)).toBe(1);
});
dispatch(target, { type: RESUME_NONE, reason: "idle" });

// With no transport waiting, this stream is handled by the observer path.
dispatch(target, { type: RESUMING, id: "errored-stream" });
await vi.waitFor(() => {
expect(
container.querySelector('[data-testid="streaming"]')?.textContent
).toBe("true");
});

dispatch(target, {
type: CHAT_RESPONSE,
id: "errored-stream",
body: "Network connection lost.",
done,
error: true,
replay: true
});

await vi.waitFor(() => {
expect(
container.querySelector('[data-testid="streaming"]')?.textContent
).toBe("false");
expect(
container.querySelector('[data-testid="message-count"]')?.textContent
).toBe("0");
});
expect(warnSpy).not.toHaveBeenCalled();
}
);

it("serializes overlapping resumes and never reads a cleared activeResponse (#1837)", async () => {
const { agent, target, sentMessages } = createFakeAgent({
name: "overlap",
Expand Down
Loading