Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
28 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
fb3a232
merge main
kolaworld Sep 2, 2026
f45ee6b
Merge branch 'main' into fix-1193-stream-speed
kolaworld Sep 11, 2026
b149389
Merge branch 'main' into fix-1193-stream-speed
kolaworld Sep 20, 2026
703a87e
Merge commit 'd24594b6b365b0196a3b89b437927a9fe05c66ac' into fix-1193…
tombeckenham Sep 24, 2026
d2aacbe
Merge commit '70842ac92c53ccb3f927d6f54136b73fe8b816ba' into fix-1193…
AlemTuzlak Sep 25, 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
85 changes: 51 additions & 34 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 @@ -1632,14 +1646,30 @@ export class ChatClient<
})
}

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

/** Consume chunks in order, yielding after bounded processing work. */
private async consumeChunks(
stream: AsyncIterable<StreamChunk>,
signal: AbortSignal,
beforeProcess?: (chunk: StreamChunk) => void,
): Promise<void> {
let processingTime = 0
for await (const chunk of stream) {
if (signal.aborted) break
await this.processIncomingChunk(chunk)
beforeProcess?.(chunk)
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
}
}
}

Expand All @@ -1662,9 +1692,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 @@ -1692,18 +1719,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 @@ -1771,10 +1800,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 @@ -1806,15 +1832,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.

89 changes: 89 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,89 @@
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.

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()
})
})
42 changes: 42 additions & 0 deletions packages/ai-client/tests/resume-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UIMessage> = []
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()
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down
7 changes: 2 additions & 5 deletions packages/ai-client/tests/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading