Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
28904b8
test(ai-client): cover buffered stream scheduling
kolaworld Aug 22, 2026
1352198
perf(ai-client): process stream chunks immediately
kolaworld Aug 22, 2026
7cf57c5
perf(ai-client): time-slice buffered stream processing
kolaworld Aug 22, 2026
cfbcc05
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 22, 2026
ed1d936
perf(ai-client): time-slice joined run replay
kolaworld Aug 23, 2026
987ead2
Merge branch 'main' into fix-1193-stream-speed
AlemTuzlak Aug 24, 2026
e63d28d
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 24, 2026
b53151f
Merge commit 'c7c3f9508c024a4ecb3ff2c75f4c54054a66e429' into fix-1193…
kolaworld Aug 24, 2026
7c9a856
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 25, 2026
d9177bd
fix(ai-client): coordinate concurrent stream processing
kolaworld Aug 25, 2026
bb0419c
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 25, 2026
f3147cd
fix(ai-client): include replay setup in processing budget
kolaworld Aug 25, 2026
abd0f40
test(ai-client): cover buffered stream scheduling
kolaworld Aug 22, 2026
53855db
perf(ai-client): process stream chunks immediately
kolaworld Aug 22, 2026
6e6a4d6
perf(ai-client): time-slice buffered stream processing
kolaworld Aug 22, 2026
fded8a4
perf(ai-client): time-slice joined run replay
kolaworld Aug 23, 2026
9fdc1f1
fix(ai-client): coordinate concurrent stream processing
kolaworld Aug 25, 2026
df33672
fix(ai-client): include replay setup in processing budget
kolaworld Aug 25, 2026
8b37553
ci: apply automated fixes
autofix-ci[bot] Aug 26, 2026
1a02e7a
Merge branch 'fix-1193-stream-speed' of github.com:kolaworld/ai into …
kolaworld Aug 27, 2026
a4b5fec
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 27, 2026
6b2a897
Merge commit '6881d98614eb835d3b6e230e71a4ffd021a3cc6e' into fix-1193…
kolaworld Aug 27, 2026
d35ae69
Merge branch 'main' into fix-1193-stream-speed
kolaworld Aug 28, 2026
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
5 changes: 5 additions & 0 deletions .changeset/chat-client-stream-speed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-client': patch
---

Process live chat chunks without waiting for a separate macrotask after each chunk.
2 changes: 2 additions & 0 deletions docs/chat/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ messages.forEach((message) => {
});
```

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)

TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) for streaming. Stream events contain different types of data:
Expand Down
97 changes: 64 additions & 33 deletions packages/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ interface InternalQueuedMessage extends QueuedMessage {
body?: Record<string, any>
}

const STREAM_PROCESSING_BUDGET_MS = 8

type SchedulerWithYield = {
yield?: () => Promise<void>
}

function yieldToHost(): Promise<void> {
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<InterruptDefinition<any, any, any, any>>
Expand Down Expand Up @@ -430,6 +444,8 @@ export class ChatClient<
private continuationPending = false
private subscriptionAbortController: AbortController | null = null
private processingResolve: (() => void) | null = null
private chunkProcessingTime = 0
private chunkProcessingYield: Promise<void> | null = null
/**
* `connect()` adapters push the full HTTP body into the subscribe queue, then
* wait until that queue is idle. After `send()` returns, every chunk from this
Expand Down Expand Up @@ -1702,14 +1718,42 @@ export class ChatClient<
})
}

/**
* Consume chunks from the connection subscription.
*/
private async consumeSubscription(signal: AbortSignal): Promise<void> {
const stream = this.connection.subscribe(signal)
await this.consumeChunks(this.connection.subscribe(signal), signal)
}

/** Consume chunks in order against the client-wide processing budget. */
private async consumeChunks(
stream: AsyncIterable<StreamChunk>,
signal: AbortSignal,
beforeProcess?: (chunk: StreamChunk) => void,
): Promise<void> {
for await (const chunk of stream) {
if (signal.aborted) break
await this.processIncomingChunk(chunk)
const pendingYield = this.chunkProcessingYield
if (pendingYield) {
await pendingYield
if (signal.aborted) break
}
const startedAt = performance.now()
beforeProcess?.(chunk)
this.processIncomingChunk(chunk)
this.chunkProcessingTime += performance.now() - startedAt
if (
this.chunkProcessingTime >= STREAM_PROCESSING_BUDGET_MS &&
(typeof document === 'undefined' || !document.hidden)
) {
this.chunkProcessingTime = 0
const processingYield = yieldToHost()
this.chunkProcessingYield = processingYield
try {
await processingYield
} finally {
if (this.chunkProcessingYield === processingYield) {
this.chunkProcessingYield = null
}
}
}
}
}

Expand All @@ -1732,9 +1776,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
Expand Down Expand Up @@ -1763,18 +1804,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()
}
await this.processIncomingChunk(chunk, { defer: false })
}
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.
Expand Down Expand Up @@ -1842,10 +1885,7 @@ export class ChatClient<
}
}

private async processIncomingChunk(
chunk: StreamChunk,
options?: { defer?: boolean },
): Promise<void> {
private processIncomingChunk(chunk: StreamChunk): void {
chunk = restoreInboundChunk(chunk)
if (
chunk.type === 'RUN_ERROR' &&
Expand Down Expand Up @@ -1877,15 +1917,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)
}

Expand Down
47 changes: 0 additions & 47 deletions packages/ai-client/tests/chat-client-hidden-tab-yield.test.ts

This file was deleted.

140 changes: 140 additions & 0 deletions packages/ai-client/tests/chat-client-stream-processing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ChatClient } from '../src/chat-client'
import { createMockConnectionAdapter, createTextChunks } from './test-utils'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import type { StreamChunk } from '@tanstack/ai/client'

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'),
}),
})
let macrotaskRan = false
setTimeout(() => {
macrotaskRan = true
}, 0)

await client.sendMessage('Hi')

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<void>((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()
}
})
})
16 changes: 16 additions & 0 deletions packages/ai-client/tests/chat-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Loading