diff --git a/packages/ai/src/generate-text/stream-text.ts b/packages/ai/src/generate-text/stream-text.ts index cbd842822d47..5f741a3a3524 100644 --- a/packages/ai/src/generate-text/stream-text.ts +++ b/packages/ai/src/generate-text/stream-text.ts @@ -1028,7 +1028,32 @@ class DefaultStreamTextResult< return; } - activeText.text += part.text; + // PATCH: O(N²)→O(N) text accumulation. Init _chunks lazily; replace .text + // with a lazy getter that materializes from the chunks array on read. + // _chunks is non-enumerable so it doesn't leak through + // JSON.stringify / structuredClone / spread to consumers. + if (!(activeText as any)._chunks) { + Object.defineProperty(activeText, '_chunks', { + value: [activeText.text || ''], + writable: true, + enumerable: false, + configurable: true, + }); + Object.defineProperty(activeText, 'text', { + get(): string { + const c = (this as any)._chunks; + return c.length === 1 + ? c[0] + : ((this as any)._chunks = [c.join('')])[0]; + }, + set(v: string) { + (this as any)._chunks = [v]; + }, + enumerable: true, + configurable: true, + }); + } + (activeText as any)._chunks.push(part.text); activeText.providerMetadata = part.providerMetadata ?? activeText.providerMetadata; } @@ -1077,7 +1102,29 @@ class DefaultStreamTextResult< return; } - activeReasoning.text += part.text; + // PATCH: O(N²)→O(N) reasoning accumulation. Same pattern as activeText above. + if (!(activeReasoning as any)._chunks) { + Object.defineProperty(activeReasoning, '_chunks', { + value: [activeReasoning.text || ''], + writable: true, + enumerable: false, + configurable: true, + }); + Object.defineProperty(activeReasoning, 'text', { + get(): string { + const c = (this as any)._chunks; + return c.length === 1 + ? c[0] + : ((this as any)._chunks = [c.join('')])[0]; + }, + set(v: string) { + (this as any)._chunks = [v]; + }, + enumerable: true, + configurable: true, + }); + } + (activeReasoning as any)._chunks.push(part.text); activeReasoning.providerMetadata = part.providerMetadata ?? activeReasoning.providerMetadata; } diff --git a/packages/ai/src/ui/process-ui-message-stream.ts b/packages/ai/src/ui/process-ui-message-stream.ts index e904c1999c05..5b58333cd076 100644 --- a/packages/ai/src/ui/process-ui-message-stream.ts +++ b/packages/ai/src/ui/process-ui-message-stream.ts @@ -390,6 +390,29 @@ export function processUIMessageStream({ providerMetadata: chunk.providerMetadata, state: 'streaming', }; + // PATCH: O(N²)→O(N) text accumulation via _chunks + lazy getter. + // See https://github.com/vercel/ai/issues/15670 for context. + // _chunks is non-enumerable so it doesn't leak through + // JSON.stringify / structuredClone / spread to consumers. + Object.defineProperty(textPart, '_chunks', { + value: [textPart.text || ''], + writable: true, + enumerable: false, + configurable: true, + }); + Object.defineProperty(textPart, 'text', { + get(): string { + const c = (this as any)._chunks; + return c.length === 1 + ? c[0] + : ((this as any)._chunks = [c.join('')])[0]; + }, + set(v: string) { + (this as any)._chunks = [v]; + }, + enumerable: true, + configurable: true, + }); state.activeTextParts[chunk.id] = textPart; state.message.parts.push(textPart); write(); @@ -407,7 +430,8 @@ export function processUIMessageStream({ `Ensure a "text-start" chunk is sent before any "text-delta" chunks.`, }); } - textPart.text += chunk.delta; + // PATCH: see text-start above — chunks instead of += to avoid O(N²). + (textPart as any)._chunks.push(chunk.delta); textPart.providerMetadata = chunk.providerMetadata ?? textPart.providerMetadata; write(); @@ -451,6 +475,28 @@ export function processUIMessageStream({ providerMetadata: chunk.providerMetadata, state: 'streaming', }; + // PATCH: O(N²)→O(N) reasoning accumulation via _chunks + lazy getter. + // _chunks is non-enumerable so it doesn't leak through + // JSON.stringify / structuredClone / spread to consumers. + Object.defineProperty(reasoningPart, '_chunks', { + value: [reasoningPart.text || ''], + writable: true, + enumerable: false, + configurable: true, + }); + Object.defineProperty(reasoningPart, 'text', { + get(): string { + const c = (this as any)._chunks; + return c.length === 1 + ? c[0] + : ((this as any)._chunks = [c.join('')])[0]; + }, + set(v: string) { + (this as any)._chunks = [v]; + }, + enumerable: true, + configurable: true, + }); state.activeReasoningParts[chunk.id] = reasoningPart; state.message.parts.push(reasoningPart); write(); @@ -468,7 +514,8 @@ export function processUIMessageStream({ `Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.`, }); } - reasoningPart.text += chunk.delta; + // PATCH: see reasoning-start above — chunks instead of += to avoid O(N²). + (reasoningPart as any)._chunks.push(chunk.delta); reasoningPart.providerMetadata = chunk.providerMetadata ?? reasoningPart.providerMetadata; write();