Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chat-hydrate-strict-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-client': patch
---

Send one hydrate `GET` when a chat with `persistence: true` mounts in React Strict Mode. Strict Mode attaches, detaches and attaches the client again in dev, and each attach sent its own `GET`. A re-attach now reuses the `GET` that is still in flight.
7 changes: 7 additions & 0 deletions .changeset/durable-batch-live-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai': patch
---

Stream durable responses live again. With `durability` set, a chunk waited in the append batch until 32 chunks arrived or the run finished, so a short reply showed up all at once at the end. Now the batch also flushes when the model sends no new chunk for 50ms. You do not need `batch: 1` for live text any more. `batch` stays the largest number of chunks in one append.

Set the wait with the new `batchWaitMs` option on `durability` (`toServerSentEventsResponse`, `toHttpResponse`) and on `toWebSocketStream` / `toWebSocketResponse`. A higher value means fewer writes to the log but slower live text. `0` appends every chunk on its own.
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@
"label": "Advanced",
"to": "resumable-streams/advanced",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-20"
"updatedAt": "2026-10-05"
},
{
"label": "WebSockets",
Expand Down
21 changes: 19 additions & 2 deletions docs/resumable-streams/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ export async function POST(request: Request) {
runId,
})
return toServerSentEventsResponse(stream, {
durability: { adapter: durableStream(request, durableOptions), batch: 32 },
durability: {
adapter: durableStream(request, durableOptions),
batch: 32,
batchWaitMs: 50,
},
})
}
```

- `headers` takes a static object for fixed credentials or an async resolver for
rotating tokens. The resolver runs for every create, append, read, and close.
- `batch` controls how many chunks are buffered per log append (default 32).
- The backend must return a non-empty `Stream-Next-Offset` header on create,
append, and close. A missing header fails loudly. The adapter never guesses an
offset.
Expand All @@ -69,6 +72,20 @@ export async function POST(request: Request) {
not need it: see
[Resuming a run without duplicating what you already streamed](#resuming-a-run-without-duplicating-what-you-already-streamed).

### Batch the log writes

Every append to a remote backend is one request. A chunk reaches the client only
after its append. So two options on `durability` trade writes against how fast
the text appears:

- `batch`: the most chunks in one append. The default is 32.
- `batchWaitMs`: the most milliseconds that a chunk waits for more chunks. Then
its batch is appended and sent. The default is 50.

If your backend charges per write, raise `batchWaitMs` and `batch`. The text then
arrives in bigger steps. If the reply arrives in large blocks, lower
`batchWaitMs`. Set it to `0` to append every chunk on its own.

## Attaching to a run by id

Reconnect after a drop is automatic. To attach to a run from the start on
Expand Down
13 changes: 13 additions & 0 deletions examples/ts-react-chat/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,19 @@ export default function Header() {
<span className="font-medium">Persistent Chat</span>
</Link>

<Link
to="/durable-persistence"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<RefreshCw size={20} />
<span className="font-medium">Durable Persistence</span>
</Link>

<Link
to="/app-studio"
onClick={() => setIsOpen(false)}
Expand Down
42 changes: 42 additions & 0 deletions examples/ts-react-chat/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AppStudioRouteImport } from './routes/app-studio'
import { Route as CapabilityDemoRouteImport } from './routes/capability-demo'
import { Route as CompactionRouteImport } from './routes/compaction'
import { Route as DurablePersistenceRouteImport } from './routes/durable-persistence'
import { Route as GenerationHooksRouteImport } from './routes/generation-hooks'
import { Route as GenericInterruptsRouteImport } from './routes/generic-interrupts'
import { Route as ImageGenRouteImport } from './routes/image-gen'
Expand All @@ -38,6 +39,7 @@ import { Route as ApiAppStudioForkRouteImport } from './routes/api.app-studio-fo
import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts'
import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo'
import { Route as ApiCompactionRouteImport } from './routes/api.compaction'
import { Route as ApiDurablePersistenceRouteImport } from './routes/api.durable-persistence'
import { Route as ApiGenericInterruptsRouteImport } from './routes/api.generic-interrupts'
import { Route as ApiImageGenRouteImport } from './routes/api.image-gen'
import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro'
Expand Down Expand Up @@ -104,6 +106,11 @@ const CompactionRoute = CompactionRouteImport.update({
path: '/compaction',
getParentRoute: () => rootRouteImport,
} as any)
const DurablePersistenceRoute = DurablePersistenceRouteImport.update({
id: '/durable-persistence',
path: '/durable-persistence',
getParentRoute: () => rootRouteImport,
} as any)
const GenerationHooksRoute = GenerationHooksRouteImport.update({
id: '/generation-hooks',
path: '/generation-hooks',
Expand Down Expand Up @@ -229,6 +236,11 @@ const ApiCompactionRoute = ApiCompactionRouteImport.update({
path: '/api/compaction',
getParentRoute: () => rootRouteImport,
} as any)
const ApiDurablePersistenceRoute = ApiDurablePersistenceRouteImport.update({
id: '/api/durable-persistence',
path: '/api/durable-persistence',
getParentRoute: () => rootRouteImport,
} as any)
const ApiGenericInterruptsRoute = ApiGenericInterruptsRouteImport.update({
id: '/api/generic-interrupts',
path: '/api/generic-interrupts',
Expand Down Expand Up @@ -466,6 +478,7 @@ export interface FileRoutesByFullPath {
'/app-studio': typeof AppStudioRoute
'/capability-demo': typeof CapabilityDemoRoute
'/compaction': typeof CompactionRoute
'/durable-persistence': typeof DurablePersistenceRoute
'/generation-hooks': typeof GenerationHooksRoute
'/generic-interrupts': typeof GenericInterruptsRoute
'/image-gen': typeof ImageGenRoute
Expand All @@ -491,6 +504,7 @@ export interface FileRoutesByFullPath {
'/api/artifacts': typeof ApiArtifactsRoute
'/api/capability-demo': typeof ApiCapabilityDemoRoute
'/api/compaction': typeof ApiCompactionRoute
'/api/durable-persistence': typeof ApiDurablePersistenceRoute
'/api/generic-interrupts': typeof ApiGenericInterruptsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
Expand Down Expand Up @@ -542,6 +556,7 @@ export interface FileRoutesByTo {
'/app-studio': typeof AppStudioRoute
'/capability-demo': typeof CapabilityDemoRoute
'/compaction': typeof CompactionRoute
'/durable-persistence': typeof DurablePersistenceRoute
'/generation-hooks': typeof GenerationHooksRoute
'/generic-interrupts': typeof GenericInterruptsRoute
'/image-gen': typeof ImageGenRoute
Expand All @@ -567,6 +582,7 @@ export interface FileRoutesByTo {
'/api/artifacts': typeof ApiArtifactsRoute
'/api/capability-demo': typeof ApiCapabilityDemoRoute
'/api/compaction': typeof ApiCompactionRoute
'/api/durable-persistence': typeof ApiDurablePersistenceRoute
'/api/generic-interrupts': typeof ApiGenericInterruptsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
Expand Down Expand Up @@ -619,6 +635,7 @@ export interface FileRoutesById {
'/app-studio': typeof AppStudioRoute
'/capability-demo': typeof CapabilityDemoRoute
'/compaction': typeof CompactionRoute
'/durable-persistence': typeof DurablePersistenceRoute
'/generation-hooks': typeof GenerationHooksRoute
'/generic-interrupts': typeof GenericInterruptsRoute
'/image-gen': typeof ImageGenRoute
Expand All @@ -644,6 +661,7 @@ export interface FileRoutesById {
'/api/artifacts': typeof ApiArtifactsRoute
'/api/capability-demo': typeof ApiCapabilityDemoRoute
'/api/compaction': typeof ApiCompactionRoute
'/api/durable-persistence': typeof ApiDurablePersistenceRoute
'/api/generic-interrupts': typeof ApiGenericInterruptsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
Expand Down Expand Up @@ -697,6 +715,7 @@ export interface FileRouteTypes {
| '/app-studio'
| '/capability-demo'
| '/compaction'
| '/durable-persistence'
| '/generation-hooks'
| '/generic-interrupts'
| '/image-gen'
Expand All @@ -722,6 +741,7 @@ export interface FileRouteTypes {
| '/api/artifacts'
| '/api/capability-demo'
| '/api/compaction'
| '/api/durable-persistence'
| '/api/generic-interrupts'
| '/api/image-gen'
| '/api/image-tool-repro'
Expand Down Expand Up @@ -773,6 +793,7 @@ export interface FileRouteTypes {
| '/app-studio'
| '/capability-demo'
| '/compaction'
| '/durable-persistence'
| '/generation-hooks'
| '/generic-interrupts'
| '/image-gen'
Expand All @@ -798,6 +819,7 @@ export interface FileRouteTypes {
| '/api/artifacts'
| '/api/capability-demo'
| '/api/compaction'
| '/api/durable-persistence'
| '/api/generic-interrupts'
| '/api/image-gen'
| '/api/image-tool-repro'
Expand Down Expand Up @@ -849,6 +871,7 @@ export interface FileRouteTypes {
| '/app-studio'
| '/capability-demo'
| '/compaction'
| '/durable-persistence'
| '/generation-hooks'
| '/generic-interrupts'
| '/image-gen'
Expand All @@ -874,6 +897,7 @@ export interface FileRouteTypes {
| '/api/artifacts'
| '/api/capability-demo'
| '/api/compaction'
| '/api/durable-persistence'
| '/api/generic-interrupts'
| '/api/image-gen'
| '/api/image-tool-repro'
Expand Down Expand Up @@ -926,6 +950,7 @@ export interface RootRouteChildren {
AppStudioRoute: typeof AppStudioRoute
CapabilityDemoRoute: typeof CapabilityDemoRoute
CompactionRoute: typeof CompactionRoute
DurablePersistenceRoute: typeof DurablePersistenceRoute
GenerationHooksRoute: typeof GenerationHooksRoute
GenericInterruptsRoute: typeof GenericInterruptsRoute
ImageGenRoute: typeof ImageGenRoute
Expand All @@ -951,6 +976,7 @@ export interface RootRouteChildren {
ApiArtifactsRoute: typeof ApiArtifactsRoute
ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute
ApiCompactionRoute: typeof ApiCompactionRoute
ApiDurablePersistenceRoute: typeof ApiDurablePersistenceRoute
ApiGenericInterruptsRoute: typeof ApiGenericInterruptsRoute
ApiImageGenRoute: typeof ApiImageGenRoute
ApiImageToolReproRoute: typeof ApiImageToolReproRoute
Expand Down Expand Up @@ -1027,6 +1053,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CompactionRouteImport
parentRoute: typeof rootRouteImport
}
'/durable-persistence': {
id: '/durable-persistence'
path: '/durable-persistence'
fullPath: '/durable-persistence'
preLoaderRoute: typeof DurablePersistenceRouteImport
parentRoute: typeof rootRouteImport
}
'/generation-hooks': {
id: '/generation-hooks'
path: '/generation-hooks'
Expand Down Expand Up @@ -1202,6 +1235,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiCompactionRouteImport
parentRoute: typeof rootRouteImport
}
'/api/durable-persistence': {
id: '/api/durable-persistence'
path: '/api/durable-persistence'
fullPath: '/api/durable-persistence'
preLoaderRoute: typeof ApiDurablePersistenceRouteImport
parentRoute: typeof rootRouteImport
}
'/api/generic-interrupts': {
id: '/api/generic-interrupts'
path: '/api/generic-interrupts'
Expand Down Expand Up @@ -1536,6 +1576,7 @@ const rootRouteChildren: RootRouteChildren = {
AppStudioRoute: AppStudioRoute,
CapabilityDemoRoute: CapabilityDemoRoute,
CompactionRoute: CompactionRoute,
DurablePersistenceRoute: DurablePersistenceRoute,
GenerationHooksRoute: GenerationHooksRoute,
GenericInterruptsRoute: GenericInterruptsRoute,
ImageGenRoute: ImageGenRoute,
Expand All @@ -1561,6 +1602,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiArtifactsRoute: ApiArtifactsRoute,
ApiCapabilityDemoRoute: ApiCapabilityDemoRoute,
ApiCompactionRoute: ApiCompactionRoute,
ApiDurablePersistenceRoute: ApiDurablePersistenceRoute,
ApiGenericInterruptsRoute: ApiGenericInterruptsRoute,
ApiImageGenRoute: ApiImageGenRoute,
ApiImageToolReproRoute: ApiImageToolReproRoute,
Expand Down
82 changes: 82 additions & 0 deletions examples/ts-react-chat/src/routes/api.durable-persistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createFileRoute } from '@tanstack/react-router'
import {
chat,
chatParamsFromRequestBody,
memoryStream,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructChat,
withPersistence,
} from '@tanstack/ai-persistence'
import type { StreamChunk } from '@tanstack/ai'

// In memory, so this page never touches the SQLite threads of /persistent-chat.
// A server restart clears it.
const persistence = memoryPersistence()

// "Slow model" on the page puts 30ms between text chunks, like a slower model,
// so the batching is easy to see by eye.
async function* slowText(
stream: AsyncIterable<StreamChunk>,
): AsyncIterable<StreamChunk> {
for await (const chunk of stream) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
await new Promise((resolve) => setTimeout(resolve, 30))
}
yield chunk
}
}

/**
* Server-authoritative persistence with a resumable POST: the persistence
* guide's POST plus `durability`. A reload mid-answer rejoins the run from the
* memoryStream log, because the GET reports the thread's active run.
*/
export const Route = createFileRoute('/api/durable-persistence')({
server: {
handlers: {
POST: async ({ request }) => {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
// "Old batching" on the page turns the batch wait off. A chunk then
// waits until 32 chunks or the run end, which is how durable batching
// worked before `batchWaitMs`.
const oldBatching = params.forwardedProps.oldBatching === true
const slowModel = params.forwardedProps.slowModel === true
return toServerSentEventsResponse(
slowModel ? slowText(stream) : stream,
{
durability: {
adapter: memoryStream(request),
...(oldBatching ? { batchWaitMs: 2_147_483_647 } : {}),
},
},
)
},

GET: ({ request }) => {
// A rejoin (`?offset` / `Last-Event-ID`) replays the run's log.
const durability = memoryStream(request)
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise return the stored thread plus a cursor to any active run.
// Demo only: a real app checks that the session owns the thread.
return reconstructChat(persistence, request, {
authorize: async (threadId) => threadId.length > 0,
})
},
},
},
})
Loading
Loading