From 28904b8030a269fec551f2a0f9846d6b702b2322 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 11:10:24 -0400 Subject: [PATCH 01/13] test(ai-client): cover buffered stream scheduling --- testing/e2e/src/routeTree.gen.ts | 22 ++ .../routes/chat-client-stream-processing.tsx | 255 ++++++++++++++++++ .../chat-client-stream-processing.spec.ts | 22 ++ 3 files changed, 299 insertions(+) create mode 100644 testing/e2e/src/routes/chat-client-stream-processing.tsx create mode 100644 testing/e2e/tests/chat-client-stream-processing.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 6e3c8ace20..7a5d63b40d 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as DevtoolsRouteARouteImport } from './routes/devtools-route-a' import { Route as DevtoolsMemoryRouteImport } from './routes/devtools-memory' import { Route as DevtoolsGenerationHooksRouteImport } from './routes/devtools-generation-hooks' import { Route as DevtoolsChatRouteImport } from './routes/devtools-chat' +import { Route as ChatClientStreamProcessingRouteImport } from './routes/chat-client-stream-processing' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' import { Route as IndexRouteImport } from './routes/index' import { Route as ProviderIndexRouteImport } from './routes/$provider/index' @@ -179,6 +180,12 @@ const DevtoolsChatRoute = DevtoolsChatRouteImport.update({ path: '/devtools-chat', getParentRoute: () => rootRouteImport, } as any) +const ChatClientStreamProcessingRoute = + ChatClientStreamProcessingRouteImport.update({ + id: '/chat-client-stream-processing', + path: '/chat-client-stream-processing', + getParentRoute: () => rootRouteImport, + } as any) const ChatClientDefaultBridgeRoute = ChatClientDefaultBridgeRouteImport.update({ id: '/chat-client-default-bridge', path: '/chat-client-default-bridge', @@ -525,6 +532,7 @@ const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -609,6 +617,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -694,6 +703,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -780,6 +790,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -864,6 +875,7 @@ export interface FileRouteTypes { to: | '/' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -948,6 +960,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -1033,6 +1046,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute + ChatClientStreamProcessingRoute: typeof ChatClientStreamProcessingRoute DevtoolsChatRoute: typeof DevtoolsChatRoute DevtoolsGenerationHooksRoute: typeof DevtoolsGenerationHooksRoute DevtoolsMemoryRoute: typeof DevtoolsMemoryRoute @@ -1231,6 +1245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevtoolsChatRouteImport parentRoute: typeof rootRouteImport } + '/chat-client-stream-processing': { + id: '/chat-client-stream-processing' + path: '/chat-client-stream-processing' + fullPath: '/chat-client-stream-processing' + preLoaderRoute: typeof ChatClientStreamProcessingRouteImport + parentRoute: typeof rootRouteImport + } '/chat-client-default-bridge': { id: '/chat-client-default-bridge' path: '/chat-client-default-bridge' @@ -1750,6 +1771,7 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, + ChatClientStreamProcessingRoute: ChatClientStreamProcessingRoute, DevtoolsChatRoute: DevtoolsChatRoute, DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute, DevtoolsMemoryRoute: DevtoolsMemoryRoute, diff --git a/testing/e2e/src/routes/chat-client-stream-processing.tsx b/testing/e2e/src/routes/chat-client-stream-processing.tsx new file mode 100644 index 0000000000..5ce45591b4 --- /dev/null +++ b/testing/e2e/src/routes/chat-client-stream-processing.tsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { EventType } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' +import type { + RunAgentInputContext, + SubscribeConnectionAdapter, +} from '@tanstack/ai-react' + +export const Route = createFileRoute('/chat-client-stream-processing')({ + component: ChatClientStreamProcessingPage, +}) + +const CONTENT_CHUNK_COUNT = 2_000 +const CHUNK_WORK_MS = 0.075 + +type DrainResult = { + contentChunkCount: number + firstContentBeforeUserBlockingTask: boolean + longTaskObserverSupported: boolean + longTaskCount: number + ordered: boolean + userBlockingTaskBeforeRunFinished: boolean +} + +type TaskScheduler = { + postTask: ( + callback: () => void, + options: { priority: 'user-blocking' }, + ) => Promise +} + +declare const scheduler: TaskScheduler + +function createChunks(run: RunAgentInputContext): Array { + const messageId = 'buffered-message' + const timestamp = Date.now() + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: run.runId, + threadId: run.threadId, + timestamp, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + timestamp, + }, + ] + + for (let index = 0; index < CONTENT_CHUNK_COUNT; index++) { + chunks.push({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: String(index), + timestamp, + }) + } + + chunks.push( + { + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp, + }, + { + type: EventType.RUN_FINISHED, + runId: run.runId, + threadId: run.threadId, + timestamp, + }, + ) + + return chunks +} + +function createBufferedConnection(): SubscribeConnectionAdapter { + const queue: Array = [] + let wake: (() => void) | undefined + + return { + subscribe(signal) { + return (async function* () { + while (!signal?.aborted) { + if (queue.length === 0) { + await new Promise((resolve) => { + const onAbort = () => resolve() + wake = () => { + signal?.removeEventListener('abort', onAbort) + resolve() + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) + wake = undefined + } + + let chunk = queue.shift() + while (chunk !== undefined) { + yield chunk + chunk = queue.shift() + } + } + })() + }, + send(_messages, _data, _signal, run) { + if (run === undefined) { + return Promise.reject(new Error('Missing run context')) + } + queue.push(...createChunks(run)) + wake?.() + return Promise.resolve() + }, + } +} + +function occupyMainThread(): void { + const deadline = performance.now() + CHUNK_WORK_MS + while (performance.now() < deadline) { + // Keep each chunk cheap while making an uninterrupted drain a long task. + } +} + +function ChatClientStreamProcessingPage() { + const connection = useMemo(createBufferedConnection, []) + const userBlockingTaskRan = useRef(false) + const nextContentIndex = useRef(0) + const nextChunkIndex = useRef(0) + const ordered = useRef(true) + const firstContentBeforeUserBlockingTask = useRef( + undefined, + ) + const userBlockingTaskBeforeRunFinished = useRef(false) + const longTasks = useRef>([]) + const [complete, setComplete] = useState(false) + const [error, setError] = useState() + const [hydrated, setHydrated] = useState(false) + const [result, setResult] = useState() + const { isLoading, sendMessage } = useChat({ + connection, + onChunk(chunk) { + occupyMainThread() + + const chunkIndex = nextChunkIndex.current++ + if (chunkIndex === 0) { + ordered.current = + ordered.current && chunk.type === EventType.RUN_STARTED + } else if (chunkIndex === 1) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_START + } else if (chunkIndex <= CONTENT_CHUNK_COUNT + 1) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_CONTENT + } else if (chunkIndex === CONTENT_CHUNK_COUNT + 2) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_END + } else if (chunkIndex === CONTENT_CHUNK_COUNT + 3) { + ordered.current = + ordered.current && chunk.type === EventType.RUN_FINISHED + } else { + ordered.current = false + } + + if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) { + firstContentBeforeUserBlockingTask.current ??= + !userBlockingTaskRan.current + if (chunk.delta !== String(nextContentIndex.current)) { + ordered.current = false + } + nextContentIndex.current++ + } else if (chunk.type === EventType.RUN_FINISHED) { + userBlockingTaskBeforeRunFinished.current = userBlockingTaskRan.current + } + }, + }) + + useEffect(() => { + setHydrated(true) + }, []) + + const run = async () => { + setComplete(false) + setError(undefined) + setResult(undefined) + userBlockingTaskRan.current = false + nextContentIndex.current = 0 + nextChunkIndex.current = 0 + ordered.current = true + firstContentBeforeUserBlockingTask.current = undefined + userBlockingTaskBeforeRunFinished.current = false + longTasks.current = [] + + const observer = new PerformanceObserver((list) => { + longTasks.current.push(...list.getEntries()) + }) + observer.observe({ type: 'longtask', buffered: true }) + const startedAt = performance.now() + void scheduler.postTask( + () => { + userBlockingTaskRan.current = true + }, + { priority: 'user-blocking' }, + ) + + try { + await sendMessage('Drain buffered chunks') + const finishedAt = performance.now() + await new Promise((resolve) => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ) + setResult({ + contentChunkCount: nextContentIndex.current, + firstContentBeforeUserBlockingTask: + firstContentBeforeUserBlockingTask.current ?? false, + longTaskObserverSupported: + PerformanceObserver.supportedEntryTypes.includes('longtask'), + longTaskCount: longTasks.current.filter( + (entry) => + entry.startTime < finishedAt && + entry.startTime + entry.duration > startedAt, + ).length, + ordered: + ordered.current && nextChunkIndex.current === CONTENT_CHUNK_COUNT + 4, + userBlockingTaskBeforeRunFinished: + userBlockingTaskBeforeRunFinished.current, + }) + } catch (runError) { + setError(String(runError)) + } finally { + observer.disconnect() + setComplete(true) + } + } + + return ( +
+ + {String(isLoading)} + {String(complete)} + {result !== undefined && ( + {JSON.stringify(result)} + )} + {error !== undefined && {error}} +
+ ) +} diff --git a/testing/e2e/tests/chat-client-stream-processing.spec.ts b/testing/e2e/tests/chat-client-stream-processing.spec.ts new file mode 100644 index 0000000000..2bb2eabd20 --- /dev/null +++ b/testing/e2e/tests/chat-client-stream-processing.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' + +test('ChatClient time-slices a large buffered stream', async ({ page }) => { + await page.goto('/chat-client-stream-processing') + + await page.getByTestId('run').click() + await expect(page.getByTestId('complete')).toHaveText('true') + + const result = JSON.parse( + (await page.getByTestId('result').textContent()) ?? '{}', + ) + expect(result).toEqual({ + contentChunkCount: 2_000, + firstContentBeforeUserBlockingTask: true, + longTaskObserverSupported: true, + longTaskCount: 0, + ordered: true, + userBlockingTaskBeforeRunFinished: true, + }) + await expect(page.getByTestId('loading')).toHaveText('false') + await expect(page.getByTestId('error')).toHaveCount(0) +}) From 1352198f4c051f5a77b1fd3d19887d54f5e243ea Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 11:32:41 -0400 Subject: [PATCH 02/13] perf(ai-client): process stream chunks immediately --- .changeset/chat-client-stream-speed.md | 5 ++ docs/chat/streaming.md | 2 + packages/ai-client/src/chat-client.ts | 21 ++------- .../chat-client-hidden-tab-yield.test.ts | 47 ------------------- .../chat-client-stream-processing.test.ts | 21 +++++++++ packages/ai-client/tests/test-utils.ts | 7 +-- 6 files changed, 33 insertions(+), 70 deletions(-) create mode 100644 .changeset/chat-client-stream-speed.md delete mode 100644 packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts create mode 100644 packages/ai-client/tests/chat-client-stream-processing.test.ts diff --git a/.changeset/chat-client-stream-speed.md b/.changeset/chat-client-stream-speed.md new file mode 100644 index 0000000000..53d76d8eb3 --- /dev/null +++ b/.changeset/chat-client-stream-speed.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Process live chat chunks without waiting for a separate macrotask after each chunk. diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index d5fff07a7a..960a4628c7 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -77,6 +77,8 @@ messages.forEach((message) => { }); ``` +The shared `ChatClient` processes incoming chunks immediately and in order across every framework integration. + ## Stream Events (AG-UI Protocol) TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) for streaming. Stream events contain different types of data: diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 28b236847f..39bff4d1a1 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1622,7 +1622,7 @@ export class ChatClient< const stream = this.connection.subscribe(signal) for await (const chunk of stream) { if (signal.aborted) break - await this.processIncomingChunk(chunk) + this.processIncomingChunk(chunk) } } @@ -1645,9 +1645,6 @@ export class ChatClient< * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and * clear the dead pointer so it does not retry on the next load. * - * Replay chunks are processed WITHOUT the per-chunk yield the live path uses, - * so the buffered prefix snaps in and only the genuinely-live tail streams at - * network speed — a reload looks like the run continued, not like it re-typed. */ private resumeInFlightRun(runId: string): void { const joinRun = this.connection.joinRun @@ -1685,7 +1682,7 @@ export class ChatClient< rebuilt = true this.dropTrailingInFlightAssistant() } - await this.processIncomingChunk(chunk, { defer: false }) + this.processIncomingChunk(chunk) } // Same contract as `streamResponse`: client tools may finish (and // queue a resume) while `isLoading` is still true. Wait for them @@ -1754,10 +1751,7 @@ export class ChatClient< } } - private async processIncomingChunk( - chunk: StreamChunk, - options?: { defer?: boolean }, - ): Promise { + private processIncomingChunk(chunk: StreamChunk): void { chunk = restoreInboundChunk(chunk) if ( chunk.type === 'RUN_ERROR' && @@ -1789,15 +1783,6 @@ export class ChatClient< this.processor.processChunk(chunk) this.updateRunLifecycle(chunk) this.observeInterruptState(chunk) - // Live path: yield a macrotask so the UI can paint. Skip when the page is - // hidden. Browsers clamp setTimeout there, and that wait paces stream pull. - // Replay passes defer: false so a backlog applies in one batch. - if ( - options?.defer !== false && - (typeof document === 'undefined' || !document.hidden) - ) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } this.resolveJoinedRun(chunk) } diff --git a/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts b/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts deleted file mode 100644 index b44eb0a1e7..0000000000 --- a/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ChatClient } from '../src/chat-client' -import { createMockConnectionAdapter, createTextChunks } from './test-utils' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -/** - * Live `processIncomingChunk` either awaits `setTimeout(0)` (visible / Node) - * or skips it (hidden). A marker timer scheduled *before* sendMessage has - * not run yet if sendMessage never awaited a macrotask. - */ -async function streamWithMacrotaskMarker(documentHidden?: boolean) { - if (documentHidden !== undefined) { - vi.stubGlobal('document', { hidden: documentHidden }) - } - - const chunks = createTextChunks('ab') - const client = new ChatClient({ - connection: createMockConnectionAdapter({ chunks }), - }) - let macrotaskRan = false - setTimeout(() => { - macrotaskRan = true - }, 0) - await client.sendMessage('Hi') - return macrotaskRan -} - -describe('ChatClient live yield', () => { - it('does not await a macrotask after live chunks when the page is hidden', async () => { - const macrotaskRan = await streamWithMacrotaskMarker(true) - expect(macrotaskRan).toBe(false) - }) - - it('awaits a macrotask after live chunks when the page is visible', async () => { - const macrotaskRan = await streamWithMacrotaskMarker(false) - expect(macrotaskRan).toBe(true) - }) - - it('awaits a macrotask after live chunks when document is missing', async () => { - expect(typeof document).toBe('undefined') - const macrotaskRan = await streamWithMacrotaskMarker() - expect(macrotaskRan).toBe(true) - }) -}) diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts new file mode 100644 index 0000000000..525e725faa --- /dev/null +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { ChatClient } from '../src/chat-client' +import { createMockConnectionAdapter, createTextChunks } from './test-utils' + +describe('ChatClient stream processing', () => { + it('does not wait for a macrotask after each live chunk', async () => { + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(false) + }) +}) diff --git a/packages/ai-client/tests/test-utils.ts b/packages/ai-client/tests/test-utils.ts index 4e127b3123..dec8accbdf 100644 --- a/packages/ai-client/tests/test-utils.ts +++ b/packages/ai-client/tests/test-utils.ts @@ -158,11 +158,8 @@ export function createMockConnectionAdapter( /** * Subscribe/send adapter that tests can push chunks into at any time. * - * `ChatClient.processIncomingChunk` yields a `setTimeout(0)` after each chunk - * so React can paint. A test that pushes the next batch during that gap would - * lose the wake on a naive mock (the generator is not parked, so `wake()` is - * a no-op, then the generator parks on a new waiter and the chunk sits - * forever). This helper: + * A push between reading the queue and parking the waiter would lose the wake + * on a naive mock. This helper: * - rechecks the queue after every yielded batch * - rechecks again after parking the waiter, so a push in that window still * wakes From 7cf57c5277a6c8773fdcf13c8d2f3089f6068cf9 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 12:15:31 -0400 Subject: [PATCH 03/13] perf(ai-client): time-slice buffered stream processing --- docs/chat/streaming.md | 2 +- packages/ai-client/src/chat-client.ts | 24 +++++++ .../chat-client-stream-processing.test.ts | 70 ++++++++++++++++++- .../routes/chat-client-stream-processing.tsx | 11 ++- .../chat-client-stream-processing.spec.ts | 3 + 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 960a4628c7..e68e3c592b 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -77,7 +77,7 @@ messages.forEach((message) => { }); ``` -The shared `ChatClient` processes incoming chunks immediately and in order across every framework integration. +Across every framework integration, the shared `ChatClient` processes ready live chunks in order without inserting a task between every chunk. After a bounded amount of chunk-processing work, it yields to keep the main thread responsive before continuing. ## Stream Events (AG-UI Protocol) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 39bff4d1a1..f43fdf2edb 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -73,6 +73,20 @@ interface InternalQueuedMessage extends QueuedMessage { body?: Record } +const STREAM_PROCESSING_BUDGET_MS = 8 + +type SchedulerWithYield = { + yield?: () => Promise +} + +function yieldToHost(): Promise { + const { scheduler } = globalThis as typeof globalThis & { + scheduler?: SchedulerWithYield + } + if (scheduler?.yield) return scheduler.yield() + return new Promise((resolve) => setTimeout(resolve, 0)) +} + function assertUniqueInterruptDefinitions( interrupts: | ReadonlyArray> @@ -1620,9 +1634,19 @@ export class ChatClient< */ private async consumeSubscription(signal: AbortSignal): Promise { const stream = this.connection.subscribe(signal) + let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + const startedAt = performance.now() this.processIncomingChunk(chunk) + processingTime += performance.now() - startedAt + if ( + processingTime >= STREAM_PROCESSING_BUDGET_MS && + (typeof document === 'undefined' || !document.hidden) + ) { + await yieldToHost() + processingTime = 0 + } } } diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts index 525e725faa..a6473d17f7 100644 --- a/packages/ai-client/tests/chat-client-stream-processing.test.ts +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -1,9 +1,76 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, createTextChunks } from './test-utils' +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + describe('ChatClient stream processing', () => { it('does not wait for a macrotask after each live chunk', async () => { + vi.spyOn(performance, 'now').mockReturnValue(0) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(false) + }) + + it('falls back to a timer after a full processing slice', async () => { + vi.stubGlobal('scheduler', {}) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(true) + }) + + it('uses the scheduler after a full processing slice', async () => { + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(schedulerYield).toHaveBeenCalled() + expect(macrotaskRan).toBe(false) + }) + + it('does not yield in a hidden document', async () => { + vi.stubGlobal('document', { hidden: true }) + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) const client = new ChatClient({ connection: createMockConnectionAdapter({ chunks: createTextChunks('ab'), @@ -17,5 +84,6 @@ describe('ChatClient stream processing', () => { await client.sendMessage('Hi') expect(macrotaskRan).toBe(false) + expect(schedulerYield).not.toHaveBeenCalled() }) }) diff --git a/testing/e2e/src/routes/chat-client-stream-processing.tsx b/testing/e2e/src/routes/chat-client-stream-processing.tsx index 5ce45591b4..960974d316 100644 --- a/testing/e2e/src/routes/chat-client-stream-processing.tsx +++ b/testing/e2e/src/routes/chat-client-stream-processing.tsx @@ -138,7 +138,7 @@ function ChatClientStreamProcessingPage() { const [error, setError] = useState() const [hydrated, setHydrated] = useState(false) const [result, setResult] = useState() - const { isLoading, sendMessage } = useChat({ + const { isLoading, messages, sendMessage } = useChat({ connection, onChunk(chunk) { occupyMainThread() @@ -175,6 +175,14 @@ function ChatClientStreamProcessingPage() { } }, }) + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join('') useEffect(() => { setHydrated(true) @@ -246,6 +254,7 @@ function ChatClientStreamProcessingPage() { {String(isLoading)} {String(complete)} + {assistantText} {result !== undefined && ( {JSON.stringify(result)} )} diff --git a/testing/e2e/tests/chat-client-stream-processing.spec.ts b/testing/e2e/tests/chat-client-stream-processing.spec.ts index 2bb2eabd20..7d96330126 100644 --- a/testing/e2e/tests/chat-client-stream-processing.spec.ts +++ b/testing/e2e/tests/chat-client-stream-processing.spec.ts @@ -17,6 +17,9 @@ test('ChatClient time-slices a large buffered stream', async ({ page }) => { ordered: true, userBlockingTaskBeforeRunFinished: true, }) + await expect(page.getByTestId('assistant-text')).toHaveText( + Array.from({ length: 2_000 }, (_, index) => String(index)).join(''), + ) await expect(page.getByTestId('loading')).toHaveText('false') await expect(page.getByTestId('error')).toHaveCount(0) }) From ed1d9363ad4872482b39c774327233cf21346edf Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sun, 23 Aug 2026 13:09:22 -0400 Subject: [PATCH 04/13] perf(ai-client): time-slice joined run replay --- packages/ai-client/src/chat-client.ts | 42 +++++++++++-------- .../ai-client/tests/resume-snapshot.test.ts | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index a08fd71f80..00923ede16 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1645,14 +1645,20 @@ export class ChatClient< }) } - /** - * Consume chunks from the connection subscription. - */ - private async consumeSubscription(signal: AbortSignal): Promise { - const stream = this.connection.subscribe(signal) + private consumeSubscription(signal: AbortSignal): Promise { + return this.consumeChunks(this.connection.subscribe(signal), signal) + } + + /** Consume chunks in order, yielding after bounded processing work. */ + private async consumeChunks( + stream: AsyncIterable, + signal: AbortSignal, + beforeProcess?: (chunk: StreamChunk) => void, + ): Promise { let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + beforeProcess?.(chunk) const startedAt = performance.now() this.processIncomingChunk(chunk) processingTime += performance.now() - startedAt @@ -1712,18 +1718,20 @@ export class ChatClient< if (!attached) controller.abort() }, REJOIN_CONNECT_DEADLINE_MS) try { - for await (const chunk of joinRun(runId, controller.signal)) { - if (controller.signal.aborted) break - if (!attached) { - attached = true - clearTimeout(connectTimer) - } - if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { - rebuilt = true - this.dropTrailingInFlightAssistant() - } - this.processIncomingChunk(chunk) - } + await this.consumeChunks( + joinRun(runId, controller.signal), + controller.signal, + (chunk) => { + if (!attached) { + attached = true + clearTimeout(connectTimer) + } + if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { + rebuilt = true + this.dropTrailingInFlightAssistant() + } + }, + ) // Same contract as `streamResponse`: client tools may finish (and // queue a resume) while `isLoading` is still true. Wait for them // before teardown so `drainPostStreamActions` below sees the queue. diff --git a/packages/ai-client/tests/resume-snapshot.test.ts b/packages/ai-client/tests/resume-snapshot.test.ts index 7f02d8dc96..b0cd1b85ab 100644 --- a/packages/ai-client/tests/resume-snapshot.test.ts +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -247,6 +247,48 @@ describe('ChatClient auto-rejoin after reload', () => { void client }) + it('yields after a full replay processing slice', async () => { + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + const now = vi + .spyOn(performance, 'now') + .mockImplementation(() => (time += 9)) + const joinRun = vi.fn(async function* () { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + let latest: Array = [] + const client = mountedChatClient({ + threadId: 't1', + connection, + initialResumeSnapshot: { + resumeState: { threadId: 't1', runId: 'r1' }, + }, + onMessagesChange: (messages) => { + latest = messages + }, + }) + + try { + await vi.waitFor(() => { + const assistant = latest.find((message) => message.role === 'assistant') + const text = assistant?.parts.find((part) => part.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + expect(schedulerYield).toHaveBeenCalled() + } finally { + client.dispose() + now.mockRestore() + vi.unstubAllGlobals() + } + }) + it('persistence:true hydrates history AND tails a live run from the server on mount', async () => { // Server-authoritative: the client caches no transcript and no run pointer. // On mount it calls connection.hydrate(threadId), which returns the stored From d9177bdc4d646bd65724fa8c2d30eb6a322ed0d8 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Tue, 25 Aug 2026 11:02:04 -0400 Subject: [PATCH 05/13] fix(ai-client): coordinate concurrent stream processing --- packages/ai-client/src/chat-client.ts | 30 ++++++++--- .../chat-client-stream-processing.test.ts | 51 +++++++++++++++++++ packages/ai-client/tests/chat-client.test.ts | 16 ++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 7bccfd6772..67ef9c4a33 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -430,6 +430,8 @@ export class ChatClient< private continuationPending = false private subscriptionAbortController: AbortController | null = null private processingResolve: (() => void) | null = null + private chunkProcessingTime = 0 + private chunkProcessingYield: Promise | null = null private errorReportedGeneration: number | null = null private streamGeneration = 0 // Tracks whether a queued checkForContinuation was skipped because @@ -1646,29 +1648,41 @@ export class ChatClient< }) } - private consumeSubscription(signal: AbortSignal): Promise { - return this.consumeChunks(this.connection.subscribe(signal), signal) + private async consumeSubscription(signal: AbortSignal): Promise { + await this.consumeChunks(this.connection.subscribe(signal), signal) } - /** Consume chunks in order, yielding after bounded processing work. */ + /** Consume chunks in order against the client-wide processing budget. */ private async consumeChunks( stream: AsyncIterable, signal: AbortSignal, beforeProcess?: (chunk: StreamChunk) => void, ): Promise { - let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + const pendingYield = this.chunkProcessingYield + if (pendingYield) { + await pendingYield + if (signal.aborted) break + } beforeProcess?.(chunk) const startedAt = performance.now() this.processIncomingChunk(chunk) - processingTime += performance.now() - startedAt + this.chunkProcessingTime += performance.now() - startedAt if ( - processingTime >= STREAM_PROCESSING_BUDGET_MS && + this.chunkProcessingTime >= STREAM_PROCESSING_BUDGET_MS && (typeof document === 'undefined' || !document.hidden) ) { - await yieldToHost() - processingTime = 0 + this.chunkProcessingTime = 0 + const processingYield = yieldToHost() + this.chunkProcessingYield = processingYield + try { + await processingYield + } finally { + if (this.chunkProcessingYield === processingYield) { + this.chunkProcessingYield = null + } + } } } } diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts index a6473d17f7..9e42a9c087 100644 --- a/packages/ai-client/tests/chat-client-stream-processing.test.ts +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, createTextChunks } from './test-utils' +import type { StreamChunk } from '@tanstack/ai/client' afterEach(() => { vi.restoreAllMocks() @@ -86,4 +87,54 @@ describe('ChatClient stream processing', () => { expect(macrotaskRan).toBe(false) expect(schedulerYield).not.toHaveBeenCalled() }) + + it('shares the processing budget across live and joined streams', async () => { + let releaseYield!: () => void + const schedulerYield = vi.fn( + () => + new Promise((resolve) => { + releaseYield = resolve + }), + ) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 5)) + const processed = vi.fn() + const chunk = (name: string): StreamChunk => ({ + type: 'CUSTOM', + name, + timestamp: Date.now(), + value: null, + }) + const client = new ChatClient({ + threadId: 't1', + connection: { + subscribe: async function* () { + yield chunk('live-1') + yield chunk('live-2') + }, + send: () => Promise.resolve(), + joinRun: async function* () { + yield chunk('joined') + }, + }, + initialResumeSnapshot: { + resumeState: { threadId: 't1', runId: 'r1' }, + }, + onChunk: processed, + }) + + client.subscribe() + client.attach() + try { + await vi.waitFor(() => expect(schedulerYield).toHaveBeenCalledTimes(1)) + expect(processed).toHaveBeenCalledTimes(2) + + releaseYield() + await vi.waitFor(() => expect(processed).toHaveBeenCalledTimes(3)) + expect(schedulerYield).toHaveBeenCalledTimes(1) + } finally { + client.dispose() + } + }) }) diff --git a/packages/ai-client/tests/chat-client.test.ts b/packages/ai-client/tests/chat-client.test.ts index 67429010b6..6b0008b6c8 100644 --- a/packages/ai-client/tests/chat-client.test.ts +++ b/packages/ai-client/tests/chat-client.test.ts @@ -1636,6 +1636,22 @@ describe('ChatClient', () => { expect(client.getConnectionStatus()).toBe('error') }) + it('should expose connectionStatus error when subscribe throws', async () => { + const connection = { + subscribe() { + throw new Error('subscription failed') + }, + send: async () => {}, + } + const client = new ChatClient({ connection }) + + expect(() => client.subscribe()).not.toThrow() + await vi.waitFor(() => { + expect(client.getIsSubscribed()).toBe(false) + expect(client.getConnectionStatus()).toBe('error') + }) + }) + it('should remain pending without terminal run events', async () => { const adapter = createSubscribeAdapter([ { From f3147cd52d6c7348a0c4573b3d5c6f0fd3f9a380 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Tue, 25 Aug 2026 11:59:30 -0400 Subject: [PATCH 06/13] fix(ai-client): include replay setup in processing budget --- packages/ai-client/src/chat-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 556a36cafc..e757e45a7d 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1701,8 +1701,8 @@ export class ChatClient< await pendingYield if (signal.aborted) break } - beforeProcess?.(chunk) const startedAt = performance.now() + beforeProcess?.(chunk) this.processIncomingChunk(chunk) this.chunkProcessingTime += performance.now() - startedAt if ( From abd0f40f01d4bf4fc02f212fd5e551fc0246c97b Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 11:10:24 -0400 Subject: [PATCH 07/13] test(ai-client): cover buffered stream scheduling --- testing/e2e/src/routeTree.gen.ts | 22 ++ .../routes/chat-client-stream-processing.tsx | 255 ++++++++++++++++++ .../chat-client-stream-processing.spec.ts | 22 ++ 3 files changed, 299 insertions(+) create mode 100644 testing/e2e/src/routes/chat-client-stream-processing.tsx create mode 100644 testing/e2e/tests/chat-client-stream-processing.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 3acd134ba7..6266e8122d 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as DevtoolsRouteARouteImport } from './routes/devtools-route-a' import { Route as DevtoolsMemoryRouteImport } from './routes/devtools-memory' import { Route as DevtoolsGenerationHooksRouteImport } from './routes/devtools-generation-hooks' import { Route as DevtoolsChatRouteImport } from './routes/devtools-chat' +import { Route as ChatClientStreamProcessingRouteImport } from './routes/chat-client-stream-processing' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' import { Route as ByokRouteImport } from './routes/byok' import { Route as IndexRouteImport } from './routes/index' @@ -181,6 +182,12 @@ const DevtoolsChatRoute = DevtoolsChatRouteImport.update({ path: '/devtools-chat', getParentRoute: () => rootRouteImport, } as any) +const ChatClientStreamProcessingRoute = + ChatClientStreamProcessingRouteImport.update({ + id: '/chat-client-stream-processing', + path: '/chat-client-stream-processing', + getParentRoute: () => rootRouteImport, + } as any) const ChatClientDefaultBridgeRoute = ChatClientDefaultBridgeRouteImport.update({ id: '/chat-client-default-bridge', path: '/chat-client-default-bridge', @@ -538,6 +545,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/byok': typeof ByokRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -624,6 +632,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/byok': typeof ByokRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -711,6 +720,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/byok': typeof ByokRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute + '/chat-client-stream-processing': typeof ChatClientStreamProcessingRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-memory': typeof DevtoolsMemoryRoute @@ -799,6 +809,7 @@ export interface FileRouteTypes { | '/' | '/byok' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -885,6 +896,7 @@ export interface FileRouteTypes { | '/' | '/byok' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -971,6 +983,7 @@ export interface FileRouteTypes { | '/' | '/byok' | '/chat-client-default-bridge' + | '/chat-client-stream-processing' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-memory' @@ -1058,6 +1071,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ByokRoute: typeof ByokRoute ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute + ChatClientStreamProcessingRoute: typeof ChatClientStreamProcessingRoute DevtoolsChatRoute: typeof DevtoolsChatRoute DevtoolsGenerationHooksRoute: typeof DevtoolsGenerationHooksRoute DevtoolsMemoryRoute: typeof DevtoolsMemoryRoute @@ -1257,6 +1271,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevtoolsChatRouteImport parentRoute: typeof rootRouteImport } + '/chat-client-stream-processing': { + id: '/chat-client-stream-processing' + path: '/chat-client-stream-processing' + fullPath: '/chat-client-stream-processing' + preLoaderRoute: typeof ChatClientStreamProcessingRouteImport + parentRoute: typeof rootRouteImport + } '/chat-client-default-bridge': { id: '/chat-client-default-bridge' path: '/chat-client-default-bridge' @@ -1791,6 +1812,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ByokRoute: ByokRoute, ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, + ChatClientStreamProcessingRoute: ChatClientStreamProcessingRoute, DevtoolsChatRoute: DevtoolsChatRoute, DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute, DevtoolsMemoryRoute: DevtoolsMemoryRoute, diff --git a/testing/e2e/src/routes/chat-client-stream-processing.tsx b/testing/e2e/src/routes/chat-client-stream-processing.tsx new file mode 100644 index 0000000000..5ce45591b4 --- /dev/null +++ b/testing/e2e/src/routes/chat-client-stream-processing.tsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { EventType } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' +import type { + RunAgentInputContext, + SubscribeConnectionAdapter, +} from '@tanstack/ai-react' + +export const Route = createFileRoute('/chat-client-stream-processing')({ + component: ChatClientStreamProcessingPage, +}) + +const CONTENT_CHUNK_COUNT = 2_000 +const CHUNK_WORK_MS = 0.075 + +type DrainResult = { + contentChunkCount: number + firstContentBeforeUserBlockingTask: boolean + longTaskObserverSupported: boolean + longTaskCount: number + ordered: boolean + userBlockingTaskBeforeRunFinished: boolean +} + +type TaskScheduler = { + postTask: ( + callback: () => void, + options: { priority: 'user-blocking' }, + ) => Promise +} + +declare const scheduler: TaskScheduler + +function createChunks(run: RunAgentInputContext): Array { + const messageId = 'buffered-message' + const timestamp = Date.now() + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: run.runId, + threadId: run.threadId, + timestamp, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + timestamp, + }, + ] + + for (let index = 0; index < CONTENT_CHUNK_COUNT; index++) { + chunks.push({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: String(index), + timestamp, + }) + } + + chunks.push( + { + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp, + }, + { + type: EventType.RUN_FINISHED, + runId: run.runId, + threadId: run.threadId, + timestamp, + }, + ) + + return chunks +} + +function createBufferedConnection(): SubscribeConnectionAdapter { + const queue: Array = [] + let wake: (() => void) | undefined + + return { + subscribe(signal) { + return (async function* () { + while (!signal?.aborted) { + if (queue.length === 0) { + await new Promise((resolve) => { + const onAbort = () => resolve() + wake = () => { + signal?.removeEventListener('abort', onAbort) + resolve() + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) + wake = undefined + } + + let chunk = queue.shift() + while (chunk !== undefined) { + yield chunk + chunk = queue.shift() + } + } + })() + }, + send(_messages, _data, _signal, run) { + if (run === undefined) { + return Promise.reject(new Error('Missing run context')) + } + queue.push(...createChunks(run)) + wake?.() + return Promise.resolve() + }, + } +} + +function occupyMainThread(): void { + const deadline = performance.now() + CHUNK_WORK_MS + while (performance.now() < deadline) { + // Keep each chunk cheap while making an uninterrupted drain a long task. + } +} + +function ChatClientStreamProcessingPage() { + const connection = useMemo(createBufferedConnection, []) + const userBlockingTaskRan = useRef(false) + const nextContentIndex = useRef(0) + const nextChunkIndex = useRef(0) + const ordered = useRef(true) + const firstContentBeforeUserBlockingTask = useRef( + undefined, + ) + const userBlockingTaskBeforeRunFinished = useRef(false) + const longTasks = useRef>([]) + const [complete, setComplete] = useState(false) + const [error, setError] = useState() + const [hydrated, setHydrated] = useState(false) + const [result, setResult] = useState() + const { isLoading, sendMessage } = useChat({ + connection, + onChunk(chunk) { + occupyMainThread() + + const chunkIndex = nextChunkIndex.current++ + if (chunkIndex === 0) { + ordered.current = + ordered.current && chunk.type === EventType.RUN_STARTED + } else if (chunkIndex === 1) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_START + } else if (chunkIndex <= CONTENT_CHUNK_COUNT + 1) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_CONTENT + } else if (chunkIndex === CONTENT_CHUNK_COUNT + 2) { + ordered.current = + ordered.current && chunk.type === EventType.TEXT_MESSAGE_END + } else if (chunkIndex === CONTENT_CHUNK_COUNT + 3) { + ordered.current = + ordered.current && chunk.type === EventType.RUN_FINISHED + } else { + ordered.current = false + } + + if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) { + firstContentBeforeUserBlockingTask.current ??= + !userBlockingTaskRan.current + if (chunk.delta !== String(nextContentIndex.current)) { + ordered.current = false + } + nextContentIndex.current++ + } else if (chunk.type === EventType.RUN_FINISHED) { + userBlockingTaskBeforeRunFinished.current = userBlockingTaskRan.current + } + }, + }) + + useEffect(() => { + setHydrated(true) + }, []) + + const run = async () => { + setComplete(false) + setError(undefined) + setResult(undefined) + userBlockingTaskRan.current = false + nextContentIndex.current = 0 + nextChunkIndex.current = 0 + ordered.current = true + firstContentBeforeUserBlockingTask.current = undefined + userBlockingTaskBeforeRunFinished.current = false + longTasks.current = [] + + const observer = new PerformanceObserver((list) => { + longTasks.current.push(...list.getEntries()) + }) + observer.observe({ type: 'longtask', buffered: true }) + const startedAt = performance.now() + void scheduler.postTask( + () => { + userBlockingTaskRan.current = true + }, + { priority: 'user-blocking' }, + ) + + try { + await sendMessage('Drain buffered chunks') + const finishedAt = performance.now() + await new Promise((resolve) => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ) + setResult({ + contentChunkCount: nextContentIndex.current, + firstContentBeforeUserBlockingTask: + firstContentBeforeUserBlockingTask.current ?? false, + longTaskObserverSupported: + PerformanceObserver.supportedEntryTypes.includes('longtask'), + longTaskCount: longTasks.current.filter( + (entry) => + entry.startTime < finishedAt && + entry.startTime + entry.duration > startedAt, + ).length, + ordered: + ordered.current && nextChunkIndex.current === CONTENT_CHUNK_COUNT + 4, + userBlockingTaskBeforeRunFinished: + userBlockingTaskBeforeRunFinished.current, + }) + } catch (runError) { + setError(String(runError)) + } finally { + observer.disconnect() + setComplete(true) + } + } + + return ( +
+ + {String(isLoading)} + {String(complete)} + {result !== undefined && ( + {JSON.stringify(result)} + )} + {error !== undefined && {error}} +
+ ) +} diff --git a/testing/e2e/tests/chat-client-stream-processing.spec.ts b/testing/e2e/tests/chat-client-stream-processing.spec.ts new file mode 100644 index 0000000000..2bb2eabd20 --- /dev/null +++ b/testing/e2e/tests/chat-client-stream-processing.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' + +test('ChatClient time-slices a large buffered stream', async ({ page }) => { + await page.goto('/chat-client-stream-processing') + + await page.getByTestId('run').click() + await expect(page.getByTestId('complete')).toHaveText('true') + + const result = JSON.parse( + (await page.getByTestId('result').textContent()) ?? '{}', + ) + expect(result).toEqual({ + contentChunkCount: 2_000, + firstContentBeforeUserBlockingTask: true, + longTaskObserverSupported: true, + longTaskCount: 0, + ordered: true, + userBlockingTaskBeforeRunFinished: true, + }) + await expect(page.getByTestId('loading')).toHaveText('false') + await expect(page.getByTestId('error')).toHaveCount(0) +}) From 53855db40ceb1088bb02b88525c4b28c3fe7f396 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 11:32:41 -0400 Subject: [PATCH 08/13] perf(ai-client): process stream chunks immediately --- .changeset/chat-client-stream-speed.md | 5 ++ docs/chat/streaming.md | 2 + packages/ai-client/src/chat-client.ts | 21 ++------- .../chat-client-hidden-tab-yield.test.ts | 47 ------------------- .../chat-client-stream-processing.test.ts | 21 +++++++++ packages/ai-client/tests/test-utils.ts | 7 +-- 6 files changed, 33 insertions(+), 70 deletions(-) create mode 100644 .changeset/chat-client-stream-speed.md delete mode 100644 packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts create mode 100644 packages/ai-client/tests/chat-client-stream-processing.test.ts diff --git a/.changeset/chat-client-stream-speed.md b/.changeset/chat-client-stream-speed.md new file mode 100644 index 0000000000..53d76d8eb3 --- /dev/null +++ b/.changeset/chat-client-stream-speed.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Process live chat chunks without waiting for a separate macrotask after each chunk. diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 3254bf6e81..eddc657b1b 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -77,6 +77,8 @@ messages.forEach((message) => { }); ``` +The shared `ChatClient` processes incoming chunks immediately and in order across every framework integration. + ## Stream Events (AG-UI Protocol) TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) for streaming. Stream events contain different types of data: diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 0d320cdd84..94c813b0c4 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1675,7 +1675,7 @@ export class ChatClient< const stream = this.connection.subscribe(signal) for await (const chunk of stream) { if (signal.aborted) break - await this.processIncomingChunk(chunk) + this.processIncomingChunk(chunk) } } @@ -1698,9 +1698,6 @@ export class ChatClient< * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and * clear the dead pointer so it does not retry on the next load. * - * Replay chunks are processed WITHOUT the per-chunk yield the live path uses, - * so the buffered prefix snaps in and only the genuinely-live tail streams at - * network speed — a reload looks like the run continued, not like it re-typed. */ private resumeInFlightRun(runId: string): void { const joinRun = this.connection.joinRun @@ -1739,7 +1736,7 @@ export class ChatClient< rebuilt = true this.dropTrailingInFlightAssistant() } - await this.processIncomingChunk(chunk, { defer: false }) + this.processIncomingChunk(chunk) } // Same contract as `streamResponse`: client tools may finish (and // queue a resume) while `isLoading` is still true. Wait for them @@ -1808,10 +1805,7 @@ export class ChatClient< } } - private async processIncomingChunk( - chunk: StreamChunk, - options?: { defer?: boolean }, - ): Promise { + private processIncomingChunk(chunk: StreamChunk): void { chunk = restoreInboundChunk(chunk) if ( chunk.type === 'RUN_ERROR' && @@ -1843,15 +1837,6 @@ export class ChatClient< this.processor.processChunk(chunk) this.updateRunLifecycle(chunk) this.observeInterruptState(chunk) - // Live path: yield a macrotask so the UI can paint. Skip when the page is - // hidden. Browsers clamp setTimeout there, and that wait paces stream pull. - // Replay passes defer: false so a backlog applies in one batch. - if ( - options?.defer !== false && - (typeof document === 'undefined' || !document.hidden) - ) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } this.resolveJoinedRun(chunk) } diff --git a/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts b/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts deleted file mode 100644 index b44eb0a1e7..0000000000 --- a/packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ChatClient } from '../src/chat-client' -import { createMockConnectionAdapter, createTextChunks } from './test-utils' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -/** - * Live `processIncomingChunk` either awaits `setTimeout(0)` (visible / Node) - * or skips it (hidden). A marker timer scheduled *before* sendMessage has - * not run yet if sendMessage never awaited a macrotask. - */ -async function streamWithMacrotaskMarker(documentHidden?: boolean) { - if (documentHidden !== undefined) { - vi.stubGlobal('document', { hidden: documentHidden }) - } - - const chunks = createTextChunks('ab') - const client = new ChatClient({ - connection: createMockConnectionAdapter({ chunks }), - }) - let macrotaskRan = false - setTimeout(() => { - macrotaskRan = true - }, 0) - await client.sendMessage('Hi') - return macrotaskRan -} - -describe('ChatClient live yield', () => { - it('does not await a macrotask after live chunks when the page is hidden', async () => { - const macrotaskRan = await streamWithMacrotaskMarker(true) - expect(macrotaskRan).toBe(false) - }) - - it('awaits a macrotask after live chunks when the page is visible', async () => { - const macrotaskRan = await streamWithMacrotaskMarker(false) - expect(macrotaskRan).toBe(true) - }) - - it('awaits a macrotask after live chunks when document is missing', async () => { - expect(typeof document).toBe('undefined') - const macrotaskRan = await streamWithMacrotaskMarker() - expect(macrotaskRan).toBe(true) - }) -}) diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts new file mode 100644 index 0000000000..525e725faa --- /dev/null +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { ChatClient } from '../src/chat-client' +import { createMockConnectionAdapter, createTextChunks } from './test-utils' + +describe('ChatClient stream processing', () => { + it('does not wait for a macrotask after each live chunk', async () => { + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(false) + }) +}) diff --git a/packages/ai-client/tests/test-utils.ts b/packages/ai-client/tests/test-utils.ts index 4e127b3123..dec8accbdf 100644 --- a/packages/ai-client/tests/test-utils.ts +++ b/packages/ai-client/tests/test-utils.ts @@ -158,11 +158,8 @@ export function createMockConnectionAdapter( /** * Subscribe/send adapter that tests can push chunks into at any time. * - * `ChatClient.processIncomingChunk` yields a `setTimeout(0)` after each chunk - * so React can paint. A test that pushes the next batch during that gap would - * lose the wake on a naive mock (the generator is not parked, so `wake()` is - * a no-op, then the generator parks on a new waiter and the chunk sits - * forever). This helper: + * A push between reading the queue and parking the waiter would lose the wake + * on a naive mock. This helper: * - rechecks the queue after every yielded batch * - rechecks again after parking the waiter, so a push in that window still * wakes From 6e6a4d64dd8d4b6834b84207b981ac86ee9324ea Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sat, 22 Aug 2026 12:15:31 -0400 Subject: [PATCH 09/13] perf(ai-client): time-slice buffered stream processing --- docs/chat/streaming.md | 2 +- packages/ai-client/src/chat-client.ts | 24 +++++++ .../chat-client-stream-processing.test.ts | 70 ++++++++++++++++++- .../routes/chat-client-stream-processing.tsx | 11 ++- .../chat-client-stream-processing.spec.ts | 3 + 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index eddc657b1b..cd36fb82a8 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -77,7 +77,7 @@ messages.forEach((message) => { }); ``` -The shared `ChatClient` processes incoming chunks immediately and in order across every framework integration. +Across every framework integration, the shared `ChatClient` processes ready live chunks in order without inserting a task between every chunk. After a bounded amount of chunk-processing work, it yields to keep the main thread responsive before continuing. ## Stream Events (AG-UI Protocol) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 94c813b0c4..2e1de58219 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -84,6 +84,20 @@ interface InternalQueuedMessage extends QueuedMessage { body?: Record } +const STREAM_PROCESSING_BUDGET_MS = 8 + +type SchedulerWithYield = { + yield?: () => Promise +} + +function yieldToHost(): Promise { + const { scheduler } = globalThis as typeof globalThis & { + scheduler?: SchedulerWithYield + } + if (scheduler?.yield) return scheduler.yield() + return new Promise((resolve) => setTimeout(resolve, 0)) +} + function assertUniqueInterruptDefinitions( interrupts: | ReadonlyArray> @@ -1673,9 +1687,19 @@ export class ChatClient< */ private async consumeSubscription(signal: AbortSignal): Promise { const stream = this.connection.subscribe(signal) + let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + const startedAt = performance.now() this.processIncomingChunk(chunk) + processingTime += performance.now() - startedAt + if ( + processingTime >= STREAM_PROCESSING_BUDGET_MS && + (typeof document === 'undefined' || !document.hidden) + ) { + await yieldToHost() + processingTime = 0 + } } } diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts index 525e725faa..a6473d17f7 100644 --- a/packages/ai-client/tests/chat-client-stream-processing.test.ts +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -1,9 +1,76 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, createTextChunks } from './test-utils' +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + describe('ChatClient stream processing', () => { it('does not wait for a macrotask after each live chunk', async () => { + vi.spyOn(performance, 'now').mockReturnValue(0) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(false) + }) + + it('falls back to a timer after a full processing slice', async () => { + vi.stubGlobal('scheduler', {}) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(macrotaskRan).toBe(true) + }) + + it('uses the scheduler after a full processing slice', async () => { + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('ab'), + }), + }) + let macrotaskRan = false + setTimeout(() => { + macrotaskRan = true + }, 0) + + await client.sendMessage('Hi') + + expect(schedulerYield).toHaveBeenCalled() + expect(macrotaskRan).toBe(false) + }) + + it('does not yield in a hidden document', async () => { + vi.stubGlobal('document', { hidden: true }) + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 9)) const client = new ChatClient({ connection: createMockConnectionAdapter({ chunks: createTextChunks('ab'), @@ -17,5 +84,6 @@ describe('ChatClient stream processing', () => { await client.sendMessage('Hi') expect(macrotaskRan).toBe(false) + expect(schedulerYield).not.toHaveBeenCalled() }) }) diff --git a/testing/e2e/src/routes/chat-client-stream-processing.tsx b/testing/e2e/src/routes/chat-client-stream-processing.tsx index 5ce45591b4..960974d316 100644 --- a/testing/e2e/src/routes/chat-client-stream-processing.tsx +++ b/testing/e2e/src/routes/chat-client-stream-processing.tsx @@ -138,7 +138,7 @@ function ChatClientStreamProcessingPage() { const [error, setError] = useState() const [hydrated, setHydrated] = useState(false) const [result, setResult] = useState() - const { isLoading, sendMessage } = useChat({ + const { isLoading, messages, sendMessage } = useChat({ connection, onChunk(chunk) { occupyMainThread() @@ -175,6 +175,14 @@ function ChatClientStreamProcessingPage() { } }, }) + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join('') useEffect(() => { setHydrated(true) @@ -246,6 +254,7 @@ function ChatClientStreamProcessingPage() { {String(isLoading)} {String(complete)} + {assistantText} {result !== undefined && ( {JSON.stringify(result)} )} diff --git a/testing/e2e/tests/chat-client-stream-processing.spec.ts b/testing/e2e/tests/chat-client-stream-processing.spec.ts index 2bb2eabd20..7d96330126 100644 --- a/testing/e2e/tests/chat-client-stream-processing.spec.ts +++ b/testing/e2e/tests/chat-client-stream-processing.spec.ts @@ -17,6 +17,9 @@ test('ChatClient time-slices a large buffered stream', async ({ page }) => { ordered: true, userBlockingTaskBeforeRunFinished: true, }) + await expect(page.getByTestId('assistant-text')).toHaveText( + Array.from({ length: 2_000 }, (_, index) => String(index)).join(''), + ) await expect(page.getByTestId('loading')).toHaveText('false') await expect(page.getByTestId('error')).toHaveCount(0) }) From fded8a4280bd493b0a94ebc9fd9cb87c12bca803 Mon Sep 17 00:00:00 2001 From: kolaworld Date: Sun, 23 Aug 2026 13:09:22 -0400 Subject: [PATCH 10/13] perf(ai-client): time-slice joined run replay --- packages/ai-client/src/chat-client.ts | 42 +++++++++++-------- .../ai-client/tests/resume-snapshot.test.ts | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 2e1de58219..5bad522525 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1682,14 +1682,20 @@ export class ChatClient< }) } - /** - * Consume chunks from the connection subscription. - */ - private async consumeSubscription(signal: AbortSignal): Promise { - const stream = this.connection.subscribe(signal) + private consumeSubscription(signal: AbortSignal): Promise { + return this.consumeChunks(this.connection.subscribe(signal), signal) + } + + /** Consume chunks in order, yielding after bounded processing work. */ + private async consumeChunks( + stream: AsyncIterable, + signal: AbortSignal, + beforeProcess?: (chunk: StreamChunk) => void, + ): Promise { let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + beforeProcess?.(chunk) const startedAt = performance.now() this.processIncomingChunk(chunk) processingTime += performance.now() - startedAt @@ -1750,18 +1756,20 @@ export class ChatClient< if (!attached) controller.abort() }, REJOIN_CONNECT_DEADLINE_MS) try { - for await (const chunk of joinRun(runId, controller.signal)) { - if (controller.signal.aborted) break - if (!attached) { - attached = true - clearTimeout(connectTimer) - } - if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { - rebuilt = true - this.dropTrailingInFlightAssistant() - } - this.processIncomingChunk(chunk) - } + await this.consumeChunks( + joinRun(runId, controller.signal), + controller.signal, + (chunk) => { + if (!attached) { + attached = true + clearTimeout(connectTimer) + } + if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { + rebuilt = true + this.dropTrailingInFlightAssistant() + } + }, + ) // Same contract as `streamResponse`: client tools may finish (and // queue a resume) while `isLoading` is still true. Wait for them // before teardown so `drainPostStreamActions` below sees the queue. diff --git a/packages/ai-client/tests/resume-snapshot.test.ts b/packages/ai-client/tests/resume-snapshot.test.ts index 7f02d8dc96..b0cd1b85ab 100644 --- a/packages/ai-client/tests/resume-snapshot.test.ts +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -247,6 +247,48 @@ describe('ChatClient auto-rejoin after reload', () => { void client }) + it('yields after a full replay processing slice', async () => { + const schedulerYield = vi.fn(() => Promise.resolve()) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + const now = vi + .spyOn(performance, 'now') + .mockImplementation(() => (time += 9)) + const joinRun = vi.fn(async function* () { + for (const chunk of runChunks('r1', 't1')) { + yield chunk + } + }) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + let latest: Array = [] + const client = mountedChatClient({ + threadId: 't1', + connection, + initialResumeSnapshot: { + resumeState: { threadId: 't1', runId: 'r1' }, + }, + onMessagesChange: (messages) => { + latest = messages + }, + }) + + try { + await vi.waitFor(() => { + const assistant = latest.find((message) => message.role === 'assistant') + const text = assistant?.parts.find((part) => part.type === 'text') + expect(text && 'content' in text && text.content).toBe('world') + }) + expect(schedulerYield).toHaveBeenCalled() + } finally { + client.dispose() + now.mockRestore() + vi.unstubAllGlobals() + } + }) + it('persistence:true hydrates history AND tails a live run from the server on mount', async () => { // Server-authoritative: the client caches no transcript and no run pointer. // On mount it calls connection.hydrate(threadId), which returns the stored From 9fdc1f1fe2fd16cd053d65a695694d954172dfaa Mon Sep 17 00:00:00 2001 From: kolaworld Date: Tue, 25 Aug 2026 11:02:04 -0400 Subject: [PATCH 11/13] fix(ai-client): coordinate concurrent stream processing --- packages/ai-client/src/chat-client.ts | 30 ++++++++--- .../chat-client-stream-processing.test.ts | 51 +++++++++++++++++++ packages/ai-client/tests/chat-client.test.ts | 16 ++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 5bad522525..556a36cafc 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -430,6 +430,8 @@ export class ChatClient< private continuationPending = false private subscriptionAbortController: AbortController | null = null private processingResolve: (() => void) | null = null + private chunkProcessingTime = 0 + private chunkProcessingYield: Promise | null = null private errorReportedGeneration: number | null = null private streamGeneration = 0 private continuationGeneration = 0 @@ -1682,29 +1684,41 @@ export class ChatClient< }) } - private consumeSubscription(signal: AbortSignal): Promise { - return this.consumeChunks(this.connection.subscribe(signal), signal) + private async consumeSubscription(signal: AbortSignal): Promise { + await this.consumeChunks(this.connection.subscribe(signal), signal) } - /** Consume chunks in order, yielding after bounded processing work. */ + /** Consume chunks in order against the client-wide processing budget. */ private async consumeChunks( stream: AsyncIterable, signal: AbortSignal, beforeProcess?: (chunk: StreamChunk) => void, ): Promise { - let processingTime = 0 for await (const chunk of stream) { if (signal.aborted) break + const pendingYield = this.chunkProcessingYield + if (pendingYield) { + await pendingYield + if (signal.aborted) break + } beforeProcess?.(chunk) const startedAt = performance.now() this.processIncomingChunk(chunk) - processingTime += performance.now() - startedAt + this.chunkProcessingTime += performance.now() - startedAt if ( - processingTime >= STREAM_PROCESSING_BUDGET_MS && + this.chunkProcessingTime >= STREAM_PROCESSING_BUDGET_MS && (typeof document === 'undefined' || !document.hidden) ) { - await yieldToHost() - processingTime = 0 + this.chunkProcessingTime = 0 + const processingYield = yieldToHost() + this.chunkProcessingYield = processingYield + try { + await processingYield + } finally { + if (this.chunkProcessingYield === processingYield) { + this.chunkProcessingYield = null + } + } } } } diff --git a/packages/ai-client/tests/chat-client-stream-processing.test.ts b/packages/ai-client/tests/chat-client-stream-processing.test.ts index a6473d17f7..9e42a9c087 100644 --- a/packages/ai-client/tests/chat-client-stream-processing.test.ts +++ b/packages/ai-client/tests/chat-client-stream-processing.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, createTextChunks } from './test-utils' +import type { StreamChunk } from '@tanstack/ai/client' afterEach(() => { vi.restoreAllMocks() @@ -86,4 +87,54 @@ describe('ChatClient stream processing', () => { expect(macrotaskRan).toBe(false) expect(schedulerYield).not.toHaveBeenCalled() }) + + it('shares the processing budget across live and joined streams', async () => { + let releaseYield!: () => void + const schedulerYield = vi.fn( + () => + new Promise((resolve) => { + releaseYield = resolve + }), + ) + vi.stubGlobal('scheduler', { yield: schedulerYield }) + let time = 0 + vi.spyOn(performance, 'now').mockImplementation(() => (time += 5)) + const processed = vi.fn() + const chunk = (name: string): StreamChunk => ({ + type: 'CUSTOM', + name, + timestamp: Date.now(), + value: null, + }) + const client = new ChatClient({ + threadId: 't1', + connection: { + subscribe: async function* () { + yield chunk('live-1') + yield chunk('live-2') + }, + send: () => Promise.resolve(), + joinRun: async function* () { + yield chunk('joined') + }, + }, + initialResumeSnapshot: { + resumeState: { threadId: 't1', runId: 'r1' }, + }, + onChunk: processed, + }) + + client.subscribe() + client.attach() + try { + await vi.waitFor(() => expect(schedulerYield).toHaveBeenCalledTimes(1)) + expect(processed).toHaveBeenCalledTimes(2) + + releaseYield() + await vi.waitFor(() => expect(processed).toHaveBeenCalledTimes(3)) + expect(schedulerYield).toHaveBeenCalledTimes(1) + } finally { + client.dispose() + } + }) }) diff --git a/packages/ai-client/tests/chat-client.test.ts b/packages/ai-client/tests/chat-client.test.ts index 67429010b6..6b0008b6c8 100644 --- a/packages/ai-client/tests/chat-client.test.ts +++ b/packages/ai-client/tests/chat-client.test.ts @@ -1636,6 +1636,22 @@ describe('ChatClient', () => { expect(client.getConnectionStatus()).toBe('error') }) + it('should expose connectionStatus error when subscribe throws', async () => { + const connection = { + subscribe() { + throw new Error('subscription failed') + }, + send: async () => {}, + } + const client = new ChatClient({ connection }) + + expect(() => client.subscribe()).not.toThrow() + await vi.waitFor(() => { + expect(client.getIsSubscribed()).toBe(false) + expect(client.getConnectionStatus()).toBe('error') + }) + }) + it('should remain pending without terminal run events', async () => { const adapter = createSubscribeAdapter([ { From df33672f2283fb95035d22dc71336f3fae5f7cee Mon Sep 17 00:00:00 2001 From: kolaworld Date: Tue, 25 Aug 2026 11:59:30 -0400 Subject: [PATCH 12/13] fix(ai-client): include replay setup in processing budget --- packages/ai-client/src/chat-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 556a36cafc..e757e45a7d 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1701,8 +1701,8 @@ export class ChatClient< await pendingYield if (signal.aborted) break } - beforeProcess?.(chunk) const startedAt = performance.now() + beforeProcess?.(chunk) this.processIncomingChunk(chunk) this.chunkProcessingTime += performance.now() - startedAt if ( From 8b3755371b64a565688383814031b3095783f243 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:26:41 +0000 Subject: [PATCH 13/13] ci: apply automated fixes --- scripts/lovable-gateway.models.json | 387 ++++++---------------------- 1 file changed, 82 insertions(+), 305 deletions(-) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e4..79c6babbc9 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": {