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
19 changes: 19 additions & 0 deletions .changeset/silent-paws-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"agents": minor
---

Throttle chat UI updates by default in `useAgentChat`

Streaming writes chat state once per chunk, and each write re-renders. When
chunks arrive in a burst — a resumed stream replaying a long turn, for
example — React reaches its 50-render limit and throws "Maximum update depth
exceeded", which the AI SDK reports as a failed turn even though the server
completed it (#1913).

`useAgentChat` now coalesces those updates every 50ms, which removes about 78%
of renders on a fast stream and matches the value the AI SDK documents. The
first chunk of a stream is never delayed. Pass `throttle: false` to render
every chunk as it arrives, or a number to change the interval. The deprecated
`experimental_throttle` is still honoured. Message snapshots, functional
updates, and streamed continuations resolve against the current chat store, so
coalescing renders cannot roll assistant content back to an older snapshot.
38 changes: 14 additions & 24 deletions packages/agents/src/chat/__tests__/broadcast-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,37 +243,35 @@ describe("broadcast stream state machine", () => {

// ── continuation response ────────────────────────────────────────

it("creates accumulator with existing parts for continuation", () => {
const messages = makeMessages("hi", "first response");
it("seeds a continuation from the current messages update", () => {
const currentMessages = makeMessages("hi", "current response");
const staleRenderedMessages = makeMessages("hi", "stale response");

const result = transition(idle, {
type: "response",
streamId: "s1",
messageId: "fallback-id",
chunkData: textChunk(" continued"),
chunkData: { type: "text-delta", delta: " continued" },
continuation: true,
currentMessages: messages
currentMessages: staleRenderedMessages
});

expect(result.state.status).toBe("observing");
if (result.state.status === "observing") {
expect(result.state.accumulator.messageId).toBe("msg-1");
expect(result.state.accumulator.parts.length).toBeGreaterThan(0);
}
expect(result.messagesUpdate).toBeDefined();
const messages = result.messagesUpdate!(currentMessages);
expect(messages[1].id).toBe("msg-1");
expect(messages[1].parts).toMatchObject([
{ text: "current response continued" }
]);
});

it("uses fallback messageId when no assistant message exists for continuation", () => {
const messages: UIMessage[] = [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] }
] as UIMessage[];

const result = transition(idle, {
type: "response",
streamId: "s1",
messageId: "fallback-id",
chunkData: textChunk("hello"),
continuation: true,
currentMessages: messages
continuation: true
});

if (result.state.status === "observing") {
Expand Down Expand Up @@ -559,8 +557,7 @@ describe("broadcast stream state machine", () => {
messageId: "tmp",
chunkData,
replay: true,
continuation: true,
currentMessages: current
continuation: true
});

let state = replay(idle, { type: "start", messageId: "msg-1" }).state;
Expand All @@ -571,20 +568,13 @@ describe("broadcast stream state machine", () => {
}).state;

expect(state.status).toBe("observing");
if (state.status === "observing") {
// Continuation picked up the trailing assistant's id + parts.
expect(state.accumulator.messageId).toBe("msg-1");
const texts = state.accumulator.parts.filter((p) => p.type === "text");
expect(texts.length).toBeGreaterThan(0);
}

const done = transition(state, {
type: "response",
streamId: "req-c",
messageId: "tmp",
done: true,
continuation: true,
currentMessages: current
continuation: true
});
const messages = done.messagesUpdate!(current);
// Continuation merged into the existing assistant, no extra message.
Expand Down
55 changes: 55 additions & 0 deletions packages/agents/src/chat/__tests__/chat-throttle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
chatThrottleOptions,
DEFAULT_CHAT_THROTTLE_MS,
resolveChatThrottleMs
} from "../chat-throttle";

describe("resolveChatThrottleMs", () => {
it("throttles by default, so chat is protected without any configuration", () => {
expect(resolveChatThrottleMs({})).toBe(DEFAULT_CHAT_THROTTLE_MS);
});

it("prefers an explicit throttle", () => {
expect(resolveChatThrottleMs({ throttle: 25 })).toBe(25);
});

it("accepts the deprecated experimental_throttle, which every example passes", () => {
expect(resolveChatThrottleMs({ experimental_throttle: 250 })).toBe(250);
});

it("prefers the current name when both are passed", () => {
expect(
resolveChatThrottleMs({ experimental_throttle: 250, throttle: 25 })
).toBe(25);
});

it("turns throttling off for false", () => {
expect(resolveChatThrottleMs({ throttle: false })).toBeUndefined();
});

it("treats 0 as opting out rather than as unset", () => {
expect(resolveChatThrottleMs({ throttle: 0 })).toBe(0);
expect(resolveChatThrottleMs({ experimental_throttle: 0 })).toBe(0);
});
});

describe("chatThrottleOptions", () => {
// @ai-sdk/react v3 reads `experimental_throttle`; v4 reads `throttle`. Both
// majors are in our peer range, so both names have to carry the value.
it("spells the throttle under both option names", () => {
expect(chatThrottleOptions({})).toEqual({
experimental_throttle: DEFAULT_CHAT_THROTTLE_MS,
throttle: DEFAULT_CHAT_THROTTLE_MS
});
expect(chatThrottleOptions({ throttle: 0 })).toEqual({
experimental_throttle: 0,
throttle: 0
});
});

// `false` is represented by omitting both numeric SDK options.
it("omits both names when throttling is off", () => {
expect(chatThrottleOptions({ throttle: false })).toEqual({});
});
});
12 changes: 9 additions & 3 deletions packages/agents/src/chat/__tests__/stream-accumulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ describe("StreamAccumulator", () => {
expect((result[1].parts[0] as { text: string }).text).toBe("updated");
});

it("continuation falls back to last assistant when messageId not found", () => {
it("continuation preserves the last assistant when messageId is not found", () => {
const a = acc({ messageId: "unknown-id", continuation: true });
a.applyChunk({ type: "text-start" } as StreamChunkData);
a.applyChunk({
Expand All @@ -727,7 +727,10 @@ describe("StreamAccumulator", () => {
const result = a.mergeInto([userMsg, assistantMsg]);
expect(result).toHaveLength(2);
expect(result[1].id).toBe("asst-1");
expect((result[1].parts[0] as { text: string }).text).toBe("continued");
expect(result[1].parts).toMatchObject([
{ text: "hi" },
{ text: "continued" }
]);
});

it("continuation appends when no assistant exists", () => {
Expand Down Expand Up @@ -814,7 +817,10 @@ describe("StreamAccumulator", () => {
} as StreamChunkData);
const result = a.mergeInto([first, target]);
expect(result[1].id).toBe("target-id");
expect((result[1].parts[0] as { text: string }).text).toBe("updated");
expect(result[1].parts).toMatchObject([
{ text: "target" },
{ text: "updated" }
]);
expect(result[0]).toBe(first);
});

Expand Down
30 changes: 3 additions & 27 deletions packages/agents/src/chat/broadcast-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type BroadcastStreamEvent =
replay?: boolean;
replayComplete?: boolean;
continuation?: boolean;
/** Required when continuation=true so the accumulator can pick up existing parts. */
/** @deprecated Continuations now seed from current messages in `messagesUpdate`. */
currentMessages?: UIMessage[];
}
| {
Expand Down Expand Up @@ -103,33 +103,9 @@ export function transition(
state.streamId !== event.streamId ||
isReplayedStart
) {
let messageId = event.messageId;
let existingParts: UIMessage["parts"] | undefined;
let existingMetadata: Record<string, unknown> | undefined;

if (event.continuation && event.currentMessages) {
for (let i = event.currentMessages.length - 1; i >= 0; i--) {
if (event.currentMessages[i].role === "assistant") {
messageId = event.currentMessages[i].id;
existingParts = [...event.currentMessages[i].parts];
if (event.currentMessages[i].metadata != null) {
existingMetadata = {
...(event.currentMessages[i].metadata as Record<
string,
unknown
>)
};
}
break;
}
}
}

accumulator = new StreamAccumulator({
messageId,
continuation: event.continuation,
existingParts,
existingMetadata
messageId: event.messageId,
continuation: event.continuation
});
} else {
accumulator = state.accumulator;
Expand Down
84 changes: 84 additions & 0 deletions packages/agents/src/chat/chat-throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* How often chat state is allowed to re-render the UI.
*
* The AI SDK writes chat state once per streamed chunk, and each write is a
* React render. Without a throttle a burst of chunks becomes a burst of
* renders, and past 50 in an unbroken row React throws "Maximum update depth
* exceeded" (#1913). A throttle collapses those renders no matter how many
* chunks arrive, which is why it protects cases chunk merging cannot: a replay
* of many tool steps, or any other backlog delivered in one go.
*
* Any value above zero prevents that crash, because the update then arrives
* from a timer rather than from the current task. The size of the value is a
* cost decision instead, measured over a 200-chunk turn at ~100 chunks/sec:
*
* off 404 commits / 138ms 50ms 90 commits / 37ms
* 16ms 261 commits / 89ms 100ms 48 commits / 21ms
*
* 50ms removes 78% of the renders. Going higher saves progressively less and
* makes the text lag further behind the stream, so this sits at the knee of
* that curve. It is also the value the AI SDK's own documentation uses.
*
* This does not delay the first chunk. The SDK throttles with `throttleit`,
* which runs the first call of an idle window immediately, so only mid-stream
* updates are coalesced.
*
* It is a mitigation rather than a guarantee. `useChat` throttles the store
* subscription, but its `getSnapshot` returns a new messages array on every
* chunk, and React forces a synchronous re-render whenever that identity moved
* during a render — a path the throttle never sees (vercel/ai#6166, fix open in
* vercel/ai#17893). Only writing state less often bounds it, which is why the
* transport also merges replayed chunks before they reach the SDK.
*/
export const DEFAULT_CHAT_THROTTLE_MS = 50;

export type ChatThrottleOptions = {
/**
* Milliseconds to coalesce chat updates, or `false` to render every chunk.
*/
throttle?: number | false;
/** @deprecated Use `throttle`. */
experimental_throttle?: number;
};

/** What gets forwarded to `useChat`. Omitted keys mean "do not throttle". */
type ForwardedThrottleOptions = {
throttle?: number;
experimental_throttle?: number;
};

/**
* Picks the throttle from an explicit caller value, the deprecated alias, or
* the default — in that order. `false` turns throttling off.
*/
export function resolveChatThrottleMs(
options: ChatThrottleOptions
): number | undefined {
if (options.throttle === false) return undefined;
return (
options.throttle ??
options.experimental_throttle ??
DEFAULT_CHAT_THROTTLE_MS
);
}

/**
* The throttle spelled under both option names, or neither name when it is off.
*
* The two names are not interchangeable across the peer range. `@ai-sdk/react`
* v3 only reads `experimental_throttle`; v4 renamed it to `throttle` and reads
* `throttle ?? experimental_throttle`. Our peer range allows both majors, so
* sending one name would silently do nothing on the other. Unknown option keys
* are ignored by both, so sending both names is safe.
*
* Turning throttling off omits both names rather than inventing a numeric
* value. Both majors also treat an explicit `0` as unthrottled, so callers that
* pass `0` keep that value under both spellings.
*/
export function chatThrottleOptions(
options: ChatThrottleOptions
): ForwardedThrottleOptions {
const ms = resolveChatThrottleMs(options);
if (ms === undefined) return {};
return { experimental_throttle: ms, throttle: ms };
}
Loading
Loading