Skip to content

Commit ccf21aa

Browse files
committed
fix(chat): handle observed terminal errors
1 parent ffc2d95 commit ccf21aa

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"agents": patch
3+
"@cloudflare/ai-chat": patch
4+
"@cloudflare/think": patch
5+
---
6+
7+
Treat `useAgentChat` observer error frames as terminal responses.
8+
9+
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.

‎packages/agents/src/chat/react.tsx‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,10 +2139,10 @@ export function useAgentChat<
21392139
}
21402140
}
21412141

2142-
if (data.done || data.replayComplete) {
2142+
if (data.done || data.error || data.replayComplete) {
21432143
pendingReplayResumeRequestIdsRef.current.delete(data.id);
21442144
}
2145-
if (data.done) {
2145+
if (data.done || data.error) {
21462146
if (
21472147
streamStateRef.current.status === "observing" &&
21482148
streamStateRef.current.streamId === data.id
@@ -2170,6 +2170,31 @@ export function useAgentChat<
21702170
) {
21712171
return;
21722172
}
2173+
if (data.error) {
2174+
pendingReplayResumeRequestIdsRef.current.delete(data.id);
2175+
customTransport.handleServerTurnCompleted(data.id);
2176+
fallbackAckedResumeRequestIdsRef.current.delete(data.id);
2177+
setIsRecovering(false);
2178+
2179+
if (
2180+
streamStateRef.current.status === "idle" ||
2181+
streamStateRef.current.streamId === data.id
2182+
) {
2183+
const result = broadcastTransition(streamStateRef.current, {
2184+
type: "clear"
2185+
});
2186+
streamStateRef.current = result.state;
2187+
setIsServerStreaming(result.isStreaming);
2188+
}
2189+
if (observedToolContinuationRequestIdRef.current === data.id) {
2190+
resetToolContinuation();
2191+
}
2192+
break;
2193+
}
2194+
2195+
// Error bodies are human-readable diagnostics, not UI message
2196+
// chunks. The transport-owned path short-circuits them before JSON
2197+
// parsing; observers must do the same.
21732198
if (data.body?.trim()) {
21742199
try {
21752200
chunkData = JSON.parse(data.body);

‎packages/agents/src/react-tests/resume-overlap-race.test.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,73 @@ function requireChat(chat: AgentChatResult | null): AgentChatResult {
124124

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

128129
beforeEach(() => {
129130
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
131+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
130132
});
131133

132134
afterEach(() => {
133135
errorSpy.mockRestore();
136+
warnSpy.mockRestore();
134137
cleanup();
135138
});
136139

140+
it("treats observed error frames as terminal without parsing or appending (#1898)", async () => {
141+
const { agent, target, sentMessages } = createFakeAgent({
142+
name: "observer-error",
143+
url: "ws://localhost:3000/agents/chat/observer-error?_pk=abc"
144+
});
145+
146+
function TestComponent() {
147+
const chat = useAgentChat({
148+
agent,
149+
getInitialMessages: null,
150+
messages: [] as UIMessage[]
151+
});
152+
return (
153+
<div>
154+
<div data-testid="message-count">{chat.messages.length}</div>
155+
<div data-testid="streaming">{String(chat.isServerStreaming)}</div>
156+
</div>
157+
);
158+
}
159+
160+
const { container } = await render(<TestComponent />);
161+
await vi.waitFor(() => {
162+
expect(countType(sentMessages, RESUME_REQUEST)).toBe(1);
163+
});
164+
dispatch(target, { type: RESUME_NONE, reason: "idle" });
165+
166+
// With no transport waiting, this stream is handled by the observer path.
167+
dispatch(target, { type: RESUMING, id: "errored-stream" });
168+
await vi.waitFor(() => {
169+
expect(
170+
container.querySelector('[data-testid="streaming"]')?.textContent
171+
).toBe("true");
172+
});
173+
174+
dispatch(target, {
175+
type: CHAT_RESPONSE,
176+
id: "errored-stream",
177+
body: "Network connection lost.",
178+
done: true,
179+
error: true,
180+
replay: true
181+
});
182+
183+
await vi.waitFor(() => {
184+
expect(
185+
container.querySelector('[data-testid="streaming"]')?.textContent
186+
).toBe("false");
187+
expect(
188+
container.querySelector('[data-testid="message-count"]')?.textContent
189+
).toBe("0");
190+
});
191+
expect(warnSpy).not.toHaveBeenCalled();
192+
});
193+
137194
it("serializes overlapping resumes and never reads a cleared activeResponse (#1837)", async () => {
138195
const { agent, target, sentMessages } = createFakeAgent({
139196
name: "overlap",

0 commit comments

Comments
 (0)