Skip to content

Commit d0d8672

Browse files
committed
feat: finalize ai persistence
1 parent b141e4b commit d0d8672

86 files changed

Lines changed: 7341 additions & 889 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/persistence-layer.md

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
---
22
'@tanstack/ai': minor
3-
'@tanstack/ai-sandbox': patch
43
'@tanstack/ai-client': minor
5-
'@tanstack/ai-claude-code': patch
6-
'@tanstack/ai-codex': patch
7-
'@tanstack/ai-gemini-cli': patch
8-
'@tanstack/ai-opencode': patch
94
'@tanstack/ai-persistence': minor
105
'@tanstack/ai-persistence-sql': minor
116
'@tanstack/ai-persistence-sqlite': minor
@@ -18,14 +13,44 @@
1813

1914
Persistence + resumable runs as composable `chat()` middleware.
2015

21-
`withPersistence(...)` makes any run durable: it loads/saves thread message history (server-authoritative), creates/updates run records, persists every AG-UI `StreamChunk` to an append-only event log, and persists usage. It is fully **optional** — a `chat()` with no persistence middleware is byte-for-byte unchanged, and it works for both non-sandbox and sandbox (agent-mode) runs.
16+
`withPersistence(...)` makes any run durable: it loads/saves thread message
17+
history (server-authoritative), creates/updates run records, persists every
18+
AG-UI `StreamChunk` to an append-only public event log, and persists usage. It
19+
is fully **optional** - a `chat()` with no persistence middleware is unchanged.
20+
The primary API is `AIPersistence` / `defineAIPersistence`; `ChatPersistence` /
21+
`defineChatPersistence` remain deprecated compatibility aliases.
2222

23-
**Resume.** Each persisted chunk carries an in-band, opaque `cursor` (a monotonic per-run sequence). A client that disconnects mid-run reconnects with the run's `runId` + last `cursor`; `chat({ cursor })` replays the persisted event tail after that cursor, then — for harness adapters that re-attach to their still-running in-sandbox process — continues live. The headless `ChatClient` tracks the cursor and exposes `resume()` / `getResumeState()` / `maybeAutoResume()` with an `autoResume` opt-out.
23+
**Resume.** Each persisted chunk carries an in-band, opaque `cursor` (a
24+
monotonic per-run sequence). A client that disconnects mid-run reconnects with
25+
`{ threadId, runId, cursor }`; `chat({ cursor })` replays the persisted public
26+
event tail after that cursor. The public chat hooks expose resume state and a
27+
manual `resume()` helper, with automatic resume behavior enabled unless
28+
`autoResume` is opted out.
2429

25-
**Event model.** The persisted log is the AG-UI `StreamChunk` stream itself (no parallel event type); agent activity (file changes, process output, approvals, artifacts, sandbox lifecycle) rides on well-known `CUSTOM` events catalogued in `@tanstack/ai`.
30+
**Interrupts.** Actionable waits are represented by
31+
`RUN_FINISHED.outcome.type === 'interrupt'` and resumed with AG-UI
32+
`RunAgentInput.resume[]`. Pending user-actionable interrupts block normal new
33+
input on the same thread by default. Approval custom events are legacy
34+
compatibility/projection only.
2635

27-
**Backends (shared SQL core + thin adapters).** One SQL implementation behind a minimal `SqlDriver` (`@tanstack/ai-persistence-sql`), with backends for SQLite (`-sqlite`, node:sqlite/better-sqlite3), Postgres (`-postgres`, pg), Cloudflare D1 (`-cloudflare`), and bring-your-own Drizzle (`-drizzle`) and Prisma (`-prisma`). Raw drivers auto-migrate (versioned, opt-out); ORMs own their schema. `memoryPersistence()` ships in core for tests/examples.
36+
**Event model.** Public stream events are separate from internal CAS/checkpoint
37+
events. `PublicEventStore` replays the AG-UI `StreamChunk` stream itself;
38+
`InternalEventStore` stores package-owned checkpoints that must not leak into
39+
public replay. Optional feature validation fails loudly when a requested feature
40+
is missing its required stores.
2841

29-
**Agent mode.** `@tanstack/ai-sandbox-persistence` bridges a durable SQL-backed `SandboxStore` and the durable `LockStore` into `withSandbox`, so sandbox resume and ensure-locking survive across processes. The shared `locks` capability now lives in `@tanstack/ai` (one token across the sandbox and persistence layers); `@tanstack/ai-sandbox` re-exports it for back-compat.
42+
**Backends (shared SQL core + thin adapters).** One SQL implementation behind a
43+
minimal `SqlDriver` (`@tanstack/ai-persistence-sql`), with backends for SQLite
44+
(`-sqlite`, node:sqlite/better-sqlite3), Postgres (`-postgres`, pg), Cloudflare
45+
D1 (`-cloudflare`), and bring-your-own Drizzle (`-drizzle`) and Prisma
46+
(`-prisma`). Raw drivers auto-migrate the small base schema (`runs`,
47+
`public_events`, `internal_events`, `messages`, `interrupts`, `metadata`,
48+
`_tanstack_ai_migrations`); ORMs own their schema. `memoryPersistence()` ships
49+
in core for tests/examples.
3050

31-
Approvals are persisted and a durable approval controller feeds decisions back into the existing deny-and-replay flow. Cloudflare is compile-verified (Workers runtime), Postgres runtime-verification is via Docker, and live harness re-attach is verified with the real CLIs; everything else is unit/integration-tested. The Playwright E2E suite is a follow-up.
51+
**Sandboxes, MCP, and workflows.** `@tanstack/ai-sandbox-persistence` bridges a
52+
durable SQL-backed `SandboxStore` and the durable `LockStore` into
53+
`withSandbox`, so sandbox resume and ensure-locking survive across processes.
54+
MCP persistence is app-owned metadata plus raw stream replay only. Workflow
55+
extensions are deferred to optional packages and should reuse these primitives
56+
without adding base schema cost.

docs/architecture/approval-flow-processing.md

Lines changed: 93 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ execution until the user explicitly approves or denies the action. This
4141
creates a human-in-the-loop checkpoint for sensitive operations (sending
4242
emails, making purchases, deleting data).
4343

44+
The primary durable wait signal is now the AG-UI terminal event
45+
`RUN_FINISHED.outcome.type === 'interrupt'`. The legacy
46+
`CUSTOM { name: "approval-requested" }` event is kept as a compatibility
47+
projection for older clients and UI state updates, but new persistence and
48+
resume logic should treat actionable waits as interrupts.
49+
4450
The flow spans three layers:
4551

4652
```mermaid
@@ -55,9 +61,9 @@ flowchart TD
5561

5662
| Layer | Responsibility |
5763
|-------|----------------|
58-
| **Server (TextEngine)** | `chat()` detects `needsApproval` emits CUSTOM `approval-requested` instead of executing the tool |
59-
| **StreamProcessor** | Receives CUSTOM chunk → `updateToolCallApproval()` fires `onApprovalRequest` callback |
60-
| **ChatClient** | Exposes `addToolApprovalResponse()` updates message state → triggers `checkForContinuation()` → sends new stream |
64+
| **Server (TextEngine)** | `chat()` detects `needsApproval` -> emits a `RUN_FINISHED` interrupt outcome and may project legacy `approval-requested` events |
65+
| **StreamProcessor** | Receives interrupt outcome / compatibility custom event -> `updateToolCallApproval()` -> fires `onApprovalRequest` callback |
66+
| **ChatClient** | Exposes `addToolApprovalResponse()` -> updates message state -> sends AG-UI `RunAgentInput.resume[]` entries through the next request |
6167

6268
Framework hooks (`useChat` in React, Solid, Vue, Svelte) delegate to
6369
`ChatClient`, which owns all concurrency and continuation logic.
@@ -70,15 +76,23 @@ Framework hooks (`useChat` in React, Solid, Vue, Svelte) delegate to
7076

7177
- Runs the agent loop: calls the LLM adapter, accumulates tool calls, executes
7278
tools, and re-invokes the adapter with results.
73-
- When a tool has `needsApproval: true`, the engine emits a
74-
`CUSTOM { name: "approval-requested" }` event instead of executing the tool.
75-
- The stream ends with `RUN_FINISHED { finishReason: "tool_calls" }` so the
76-
client knows tools are pending.
79+
- When a tool has `needsApproval: true`, the engine emits a terminal
80+
`RUN_FINISHED` event with `outcome.type === 'interrupt'` instead of executing
81+
the tool.
82+
- Compatibility `CUSTOM { name: "approval-requested" }` events may still be
83+
emitted or projected so older approval UI can render the same tool-call state.
84+
- Persistence stores pending interrupts and validates the later
85+
`RunAgentInput.resume[]` response before accepting normal new input on the
86+
same thread.
7787

7888
### StreamProcessor (`packages/ai/src/activities/chat/stream/processor.ts`)
7989

8090
- Single source of truth for `UIMessage[]` state.
81-
- On `approval-requested` custom event:
91+
- On `RUN_FINISHED.outcome.type === 'interrupt'`:
92+
1. Persists/surfaces each interrupt as a pending user-actionable wait.
93+
2. Calls `updateToolCallApproval()` for approval-shaped interrupts so legacy
94+
approval UI renders as before.
95+
- On legacy `approval-requested` custom event:
8296
1. Calls `updateToolCallApproval()` to set the tool-call part's state to
8397
`approval-requested` and attach approval metadata.
8498
2. Fires `onApprovalRequest` so the ChatClient can emit devtools events.
@@ -192,24 +206,31 @@ Step-by-step flow for a single tool requiring approval:
192206
TOOL_CALL_START { toolCallId: "tc-1", toolName: "send_email" }
193207
TOOL_CALL_ARGS { toolCallId: "tc-1", delta: '{"to":"..."}' }
194208
TOOL_CALL_END { toolCallId: "tc-1" }
195-
CUSTOM { name: "approval-requested", value: {
196-
toolCallId: "tc-1",
197-
toolName: "send_email",
198-
input: { to: "..." },
199-
approval: { id: "appr-1", needsApproval: true }
209+
RUN_FINISHED { outcome: {
210+
type: "interrupt",
211+
interrupts: [{
212+
id: "appr-1",
213+
reason: "approval_required",
214+
metadata: {
215+
kind: "approval",
216+
toolCallId: "tc-1",
217+
toolName: "send_email",
218+
input: { to: "..." }
219+
}
220+
}]
200221
}}
201-
RUN_FINISHED { finishReason: "tool_calls" }
202222
```
203223

204-
### 2. StreamProcessor processes the CUSTOM chunk
224+
### 2. StreamProcessor processes the interrupt outcome
205225

206226
```
207-
handleCustomEvent():
227+
handleRunFinished():
208228
1. updateToolCallApproval(messages, messageId, "tc-1", "appr-1")
209229
→ Sets part.state = "approval-requested"
210230
→ Sets part.approval = { id: "appr-1", needsApproval: true }
211-
2. emitMessagesChange()
212-
3. fires onApprovalRequest({ toolCallId, toolName, input, approvalId })
231+
2. records the pending interrupt on the client
232+
3. emitMessagesChange()
233+
4. fires onApprovalRequest({ toolCallId, toolName, input, approvalId })
213234
```
214235

215236
### 3. Stream ends, ChatClient processes
@@ -233,7 +254,9 @@ addToolApprovalResponse({ id: "appr-1", approved: true }):
233254
→ updateToolCallApprovalResponse():
234255
part.approval.approved = true
235256
part.state = "approval-responded"
236-
2. isLoading is false → call checkForContinuation() directly
257+
2. queue a resume entry:
258+
{ interruptId: "appr-1", status: "resolved", payload: { approved: true } }
259+
3. isLoading is false → call checkForContinuation() directly
237260
```
238261

239262
### 5. Continuation
@@ -244,8 +267,9 @@ checkForContinuation():
244267
2. shouldAutoSend() → areAllToolsComplete():
245268
part.state === "approval-responded" → true
246269
3. continuationPending = true
247-
4. streamResponse() → new stream to server with approval in messages
248-
5. Server sees approval, executes tool, returns result + LLM response
270+
4. streamResponse() → new stream to server with `resume[]`
271+
5. Persistence validates `resume[]`, resolves the pending interrupt, then the
272+
server executes or cancels the tool and returns result + LLM response
249273
6. continuationPending = false
250274
```
251275

@@ -388,11 +412,52 @@ Timeline (with fix):
388412

389413
### Approval-related AG-UI events
390414

391-
These are `CUSTOM` events emitted by the TextEngine, not by adapters directly.
415+
The primary wait event is `RUN_FINISHED` with an interrupt outcome:
416+
417+
```typescript ignore
418+
{
419+
type: 'RUN_FINISHED',
420+
outcome: {
421+
type: 'interrupt',
422+
interrupts: [
423+
{
424+
id: string,
425+
reason: 'approval_required',
426+
metadata: {
427+
kind: 'approval',
428+
toolCallId: string,
429+
toolName: string,
430+
input: unknown
431+
}
432+
}
433+
]
434+
}
435+
}
436+
```
437+
438+
Resume uses AG-UI `RunAgentInput.resume[]` on the next request:
439+
440+
```typescript ignore
441+
{
442+
resume: [
443+
{
444+
interruptId: 'appr-1',
445+
status: 'resolved',
446+
payload: { approved: true }
447+
}
448+
]
449+
}
450+
```
451+
452+
Pending user-actionable interrupts block normal input on the same thread by
453+
default. A stale cursor replay may read the public event tail, but a new
454+
non-resume input must resolve the pending interrupts first.
392455

393456
#### `approval-requested`
394457

395-
Emitted when a tool with `needsApproval: true` has its arguments finalized.
458+
Legacy compatibility/projection event emitted when a tool with
459+
`needsApproval: true` has its arguments finalized. Use this to keep older UI
460+
rendering paths working; do not use it as the primary durable wait signal.
396461

397462
```typescript ignore
398463
{
@@ -422,13 +487,15 @@ TOOL_CALL_START → creates tool-call part (state: awaiting-input)
422487
TOOL_CALL_ARGS* → accumulates arguments (state: input-streaming)
423488
TOOL_CALL_END → finalizes arguments (state: input-complete)
424489
CUSTOM → approval-requested (state: approval-requested)
425-
RUN_FINISHED → finishReason: "tool_calls"
490+
RUN_FINISHED → outcome.type: "interrupt"
426491
```
427492

428493
After the stream ends and the user responds, the ChatClient:
429494
1. Updates the tool-call part (state: `approval-responded`)
430-
2. Sends a new stream request with the full conversation (including approval)
431-
3. The server sees the approval and either executes or cancels the tool
495+
2. Sends a new stream request with `RunAgentInput.resume[]` entries resolving
496+
the pending interrupt
497+
3. Persistence validates the resume entries, resolves the interrupt, and the
498+
server executes or cancels the tool based on the resume payload
432499

433500
---
434501

docs/chat/connection-adapters.md

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
7373

7474
const { messages } = useChat({
7575
connection: fetchServerSentEvents("/api/chat", {
76-
body: { provider: "openai", model: "gpt-5.1" },
76+
body: { provider: "openai", model: "gpt-5.5" },
7777
}),
7878
});
7979
```
@@ -178,7 +178,7 @@ import { chatServerFn } from "./server/chat.server";
178178

179179
// `chatServerFn` is an in-process server-side function that synchronously
180180
// returns an AsyncIterable<StreamChunk> — e.g. the result of
181-
// `chat({ adapter, model, messages })` on the server.
181+
// `chat({ adapter: openaiText("gpt-5.5"), messages })` on the server.
182182
const { messages } = useChat({
183183
connection: stream((messages, data) => chatServerFn({ messages, ...data })),
184184
});
@@ -197,13 +197,28 @@ When you call into your server with an **async** function — the universal case
197197
import { createServerFn } from "@tanstack/react-start";
198198
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
199199
import { openaiText } from "@tanstack/ai-openai";
200-
import type { UIMessage } from "@tanstack/ai";
200+
import type { RunAgentResumeItem, UIMessage } from "@tanstack/ai";
201+
202+
type ChatFnInput = {
203+
messages: Array<UIMessage>;
204+
threadId?: string;
205+
runId?: string;
206+
cursor?: string;
207+
resume?: Array<RunAgentResumeItem>;
208+
};
201209

202210
export const chatFn = createServerFn({ method: "POST" })
203-
.inputValidator((data: { messages: Array<UIMessage> }) => data)
211+
.inputValidator((data: ChatFnInput) => data)
204212
.handler(({ data }) =>
205213
toServerSentEventsResponse(
206-
chat({ adapter: openaiText("gpt-5.1"), messages: data.messages }),
214+
chat({
215+
adapter: openaiText("gpt-5.5"),
216+
messages: data.messages,
217+
threadId: data.threadId,
218+
runId: data.runId,
219+
cursor: data.cursor,
220+
resume: data.resume,
221+
}),
207222
),
208223
);
209224
```
@@ -213,11 +228,11 @@ import { useChat } from "@tanstack/ai-react";
213228
import { chatFn } from "./server/chat.server";
214229

215230
const { messages, sendMessage } = useChat({
216-
fetcher: ({ messages }, { signal }) => chatFn({ data: { messages }, signal }),
231+
fetcher: (input, { signal }) => chatFn({ data: input, signal }),
217232
});
218233
```
219234

220-
The fetcher receives `{ messages, data, threadId, runId }` plus an `AbortSignal` (triggered by `stop()` or when a send is superseded). Return a `Response` — whose SSE body the chat client parses for you — **or** an `AsyncIterable<StreamChunk>`, which is yielded directly. If your server function returns the stream itself (instead of wrapping it in a `Response`), the fetcher handles that too. Sync and `Promise`-wrapped returns are both accepted.
235+
The fetcher receives `{ messages, data, threadId, runId, cursor, resume }` plus an `AbortSignal` (triggered by `stop()` or when a send is superseded). Forward `cursor` when replaying persisted public events, and forward `resume` when responding to pending AG-UI interrupts. Return a `Response` — whose SSE body the chat client parses for you — **or** an `AsyncIterable<StreamChunk>`, which is yielded directly. If your server function returns the stream itself (instead of wrapping it in a `Response`), the fetcher handles that too. Sync and `Promise`-wrapped returns are both accepted.
221236

222237
> **Tip:** The choice between `fetcher` and [`stream()`](#server-functions-and-direct-async-iterables) is about **async vs sync**, not `Response`-vs-iterable — both can yield an `AsyncIterable<StreamChunk>`. `stream()`'s factory must return that iterable **synchronously**, so a server-function call (which returns a `Promise`) won't typecheck there — that's the gap `fetcher` fills ([issue #509](https://github.com/TanStack/ai/issues/509)). Use `stream()` when you can hand back an async iterable synchronously (in-process `chat()`, an RPC client, tests); use `fetcher` for anything you have to `await`. Both normalize to the same request-scoped adapter, so `stop()`/abort, error handling, and tool calls behave identically.
223238
@@ -315,6 +330,8 @@ function websocketConnection(url: string): SubscribeConnectionAdapter {
315330
JSON.stringify({
316331
threadId: runContext?.threadId,
317332
runId: runContext?.runId,
333+
cursor: runContext?.cursor,
334+
resume: runContext?.resume,
318335
messages,
319336
data,
320337
}),
@@ -382,6 +399,8 @@ const myAdapter: ConnectConnectionAdapter = {
382399
body: JSON.stringify({
383400
threadId: runContext?.threadId,
384401
runId: runContext?.runId,
402+
cursor: runContext?.cursor,
403+
resume: runContext?.resume,
385404
messages,
386405
...data,
387406
}),
@@ -415,20 +434,22 @@ const myAdapter: ConnectConnectionAdapter = {
415434
const { messages } = useChat({ connection: myAdapter });
416435
```
417436

418-
`runContext` carries `threadId`, `runId`, `clientTools`, and `forwardedProps`. Include them in your request payload so the server can build an AG-UI-compliant response. If your `connect` stream completes without emitting `RUN_FINISHED`, the runtime synthesizes one for you; if it throws, a `RUN_ERROR` is synthesized.
437+
`runContext` carries `threadId`, `runId`, `cursor`, `resume`, `clientTools`, and `forwardedProps`. Include them in your request payload so the server can build an AG-UI-compliant response, replay from `{ threadId, runId, cursor }`, and validate `RunAgentInput.resume[]` for pending interrupts. If your `connect` stream completes without emitting `RUN_FINISHED`, the runtime synthesizes one for you; if it throws, a `RUN_ERROR` is synthesized.
419438

420439
## The Adapter Interface
421440

422441
A `ConnectionAdapter` is a union — provide **either** `connect`, **or** both `subscribe` and `send`. Never both modes.
423442

424443
```typescript
425444
import type { UIMessage } from "@tanstack/ai-client";
426-
import type { ModelMessage, StreamChunk } from "@tanstack/ai";
445+
import type { ModelMessage, RunAgentResumeItem, StreamChunk } from "@tanstack/ai";
427446

428447
export interface RunAgentInputContext {
429448
threadId: string;
430449
runId: string;
431450
parentRunId?: string;
451+
cursor?: string;
452+
resume?: Array<RunAgentResumeItem>;
432453
clientTools?: Array<{ name: string; description: string; parameters: unknown }>;
433454
forwardedProps?: Record<string, unknown>;
434455
}

docs/config.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@
160160
{
161161
"label": "Connection Adapters",
162162
"to": "chat/connection-adapters",
163-
"addedAt": "2026-04-15"
163+
"addedAt": "2026-04-15",
164+
"updatedAt": "2026-07-02"
164165
},
165166
{
166167
"label": "Thinking & Reasoning",
@@ -386,7 +387,8 @@
386387
{
387388
"label": "Overview",
388389
"to": "persistence/overview",
389-
"addedAt": "2026-06-18"
390+
"addedAt": "2026-06-18",
391+
"updatedAt": "2026-07-02"
390392
}
391393
]
392394
},

0 commit comments

Comments
 (0)