Skip to content

Commit 4017da4

Browse files
ymansurozerclaude
andcommitted
fix: serialize reconnect resume re-probes (parity with agents 0.17.2, #1837)
The socket "open" handler no longer issues a resumeStream() re-probe while one is still in flight (reconnect storm), preventing the AI SDK's shared activeResponse from being overwritten and cleared out from under an earlier resume's finalizer (which then read undefined and threw). Gate on an in-flight flag plus a generation counter so teardown invalidates an orphaned late finalizer. Hook-only; no public API or peer-floor change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4vL8mATMNDHcvGP2yoJ5c
1 parent d1f86a9 commit 4017da4

3 files changed

Lines changed: 68 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ const transport = new WebSocketChatTransport({
165165
| `cancelOnClientAbort` (local-only abort, default) ||
166166
| Pre-stream `cf_agent_stream_pending` (extended resume probe) ||
167167
| `connectionError` (terminal WebSocket close) ||
168+
| Reconnect resume serialization (in-flight gate, `agents` 0.17.2 / #1837) ||
168169
| `useAgent` connection hook | 🚧 roadmap |
169170
| Initial-message HTTP cache | 🚧 roadmap |
170171
| `isServerStreaming` / `isStreaming` flags | 🚧 roadmap |
@@ -174,7 +175,7 @@ const transport = new WebSocketChatTransport({
174175
```bash
175176
pnpm install
176177
pnpm exec playwright install chromium
177-
pnpm test # 79 tests in chromium (vitest-browser-vue)
178+
pnpm test # 80 tests in chromium (vitest-browser-vue)
178179
pnpm typecheck
179180
pnpm lint
180181
pnpm build

src/tests/use-agent-chat.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,44 @@ describe("useAgentChat — resume fallback dedupe", () => {
10641064
});
10651065
});
10661066

1067+
// ── reconnect resume serialization (#1837) ──────────────────────
1068+
//
1069+
// Upstream agents 0.17.2 (#1837): the AI SDK's Chat.makeRequest shares one mutable
1070+
// activeResponse with no concurrency guard, so overlapping resume re-probes during a
1071+
// reconnect storm crash the first resume's finalizer. The open handler must serialize
1072+
// resumes — never issue a second resumeStream() while one is still in flight — and
1073+
// reopen the gate once the in-flight resume settles.
1074+
1075+
describe("useAgentChat — reconnect resume serialization (#1837)", () => {
1076+
it("suppresses a second resume re-probe while one is in flight, then resumes again after it settles", async () => {
1077+
const { chat, client, unmount } = mountChat({ onError: () => {} });
1078+
1079+
// Keep the first re-probe pending so the in-flight gate stays closed, and observe
1080+
// how many resumeStream() calls the open handler issues.
1081+
let settleResume!: () => void;
1082+
const inFlight = new Promise<void>((resolve) => {
1083+
settleResume = resolve;
1084+
});
1085+
const resumeStream = vi.spyOn(chat.chat, "resumeStream").mockImplementation(() => inFlight);
1086+
1087+
// Reconnect storm: two socket opens in succession while the first resume is pending.
1088+
client.emit("open", new Event("open"));
1089+
client.emit("open", new Event("open"));
1090+
1091+
// Only the first open re-probes; the second is suppressed by the in-flight gate.
1092+
expect(resumeStream).toHaveBeenCalledTimes(1);
1093+
1094+
// Once the in-flight resume settles, the gate reopens and a later open resumes again.
1095+
settleResume();
1096+
await tick();
1097+
client.emit("open", new Event("open"));
1098+
expect(resumeStream).toHaveBeenCalledTimes(2);
1099+
1100+
resumeStream.mockRestore();
1101+
unmount();
1102+
});
1103+
});
1104+
10671105
// ── isRecovering (0.8.0) ────────────────────────────────────────
10681106

10691107
describe("useAgentChat — isRecovering", () => {

src/use-agent-chat.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ export function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage = U
265265
const fallbackAckedResumeRequestIds = new Set<string>();
266266
let streamState: BroadcastStreamState = { status: "idle" };
267267
let isResumingToolContinuation = false;
268+
// Serialize reconnect resume re-probes (#1837, upstream agents 0.17.2). The AI SDK's
269+
// Chat.makeRequest shares one mutable activeResponse with no concurrency guard, so a
270+
// second resumeStream() issued while the first is still in flight (reconnect storm)
271+
// overwrites+clears it before the first finalizer runs → a crash. Gate on an in-flight
272+
// flag; a generation counter lets teardown invalidate an orphaned late finalizer.
273+
let resumeInFlight = false;
274+
let resumeGeneration = 0;
268275

269276
/** True while the server is recovering a durable chat turn (distinct from streaming). */
270277
const isRecovering = ref(false);
@@ -538,8 +545,23 @@ export function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage = U
538545
// A successful (re)connection clears any prior terminal connection error.
539546
connectionError.value = null;
540547
options.onOpen?.(event);
541-
if (options.resume !== false) {
542-
void chat.resumeStream();
548+
// Re-probe the stream on open, but never overlap resume calls (#1837): skip if a
549+
// resume is already in flight, a tool continuation is resuming, or the transport is
550+
// mid resume-handshake. The gate is cleared by the in-flight resume's own finalizer.
551+
if (options.resume !== false && !resumeInFlight && !isResumingToolContinuation && !transport.isAwaitingResume()) {
552+
resumeInFlight = true;
553+
const myGeneration = resumeGeneration;
554+
void chat
555+
.resumeStream()
556+
.catch(() => {})
557+
.finally(() => {
558+
// A teardown between issue and settle bumps the generation; ignore that stale
559+
// finalizer so it can't reopen the gate on a disposed instance.
560+
if (resumeGeneration !== myGeneration) {
561+
return;
562+
}
563+
resumeInFlight = false;
564+
});
543565
}
544566
});
545567
socket.addEventListener("close", (event: Event) => {
@@ -560,6 +582,10 @@ export function useAgentChat<AgentT = unknown, ChatMessage extends UIMessage = U
560582
onScopeDispose(() => {
561583
fallbackAckedResumeRequestIds.clear();
562584
isRecovering.value = false;
585+
// Force the resume gate open and bump the generation so any orphaned in-flight
586+
// resume's late finalizer is ignored rather than reopening the gate (#1837).
587+
resumeGeneration++;
588+
resumeInFlight = false;
563589
socket.close();
564590
});
565591

0 commit comments

Comments
 (0)