workflow 5.0.0-beta.50 → 5.0.0-beta.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ai/chat-session-modeling.mdx +170 -419
- package/docs/ai/index.mdx +27 -36
- package/docs/ai/message-queueing.mdx +61 -100
- package/docs/api-reference/index.mdx +1 -1
- package/docs/api-reference/workflow-ai/durable-agent.mdx +15 -15
- package/docs/api-reference/workflow-ai/index.mdx +3 -3
- package/docs/changelog/batched-event-writes.mdx +1 -1
- package/docs/configuration/runtime-tuning.mdx +5 -0
- package/docs/configuration/worlds.mdx +1 -1
- package/docs/cookbook/advanced/serializable-steps.mdx +33 -61
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +64 -54
- package/docs/cookbook/agent-patterns/durable-agent.mdx +21 -1
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +177 -200
- package/docs/cookbook/integrations/ai-sdk.mdx +2 -2
- package/docs/errors/corrupted-event-log.mdx +11 -1
- package/docs/errors/fetch-in-workflow.mdx +4 -7
- package/docs/errors/run-expired.mdx +6 -6
- package/docs/foundations/streaming.mdx +1 -1
- package/docs/getting-started/python.mdx +69 -15
- package/docs/how-it-works/event-sourcing.mdx +27 -0
- package/docs/observability/retention.mdx +8 -6
- package/package.json +10 -10
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Chat Session Modeling
|
|
3
|
-
description: Model chat sessions at different architectural layers to control state ownership and handle interruptions.
|
|
3
|
+
description: Model WorkflowAgent chat sessions at different architectural layers to control state ownership and handle interruptions.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Choose between single-turn and multi-turn
|
|
5
|
+
summary: Choose between single-turn and multi-turn WorkflowAgent patterns for managing chat session state.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/ai
|
|
8
8
|
- /docs/foundations/workflows-and-steps
|
|
@@ -10,528 +10,278 @@ related:
|
|
|
10
10
|
- /docs/ai/message-queueing
|
|
11
11
|
- /docs/ai/resumable-streams
|
|
12
12
|
- /docs/foundations/hooks
|
|
13
|
-
- /docs/api-reference/workflow-ai/durable-agent
|
|
14
13
|
- /docs/api-reference/workflow/define-hook
|
|
15
14
|
---
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
The examples below use the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The session-modeling patterns here (single- vs multi-turn, hooks, stream reconnection) apply to either API.
|
|
19
|
-
</Callout>
|
|
20
|
-
|
|
21
|
-
Chat sessions in AI agents can be modeled at different layers of your architecture. The choice affects state ownership and how you handle interruptions and reconnections.
|
|
16
|
+
Chat sessions can be modeled at different layers of your architecture. The choice determines who owns message history, how long a workflow run stays active, and how clients reconnect after an interruption.
|
|
22
17
|
|
|
23
|
-
|
|
18
|
+
Workflow 5 applications should use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for both patterns.
|
|
24
19
|
|
|
25
20
|
## Single-turn workflows
|
|
26
21
|
|
|
27
|
-
Each user
|
|
22
|
+
Each user turn starts a new workflow run. The client or API owns conversation history and sends the complete `UIMessage[]` array with every request.
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
### Workflow
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
Convert UI messages to model messages inside the workflow. `WorkflowAgent` writes durable `ModelCallStreamPart` values to the run stream.
|
|
32
27
|
|
|
33
|
-
```typescript title="workflows/chat
|
|
34
|
-
import {
|
|
28
|
+
```typescript title="workflows/chat.ts" lineNumbers
|
|
29
|
+
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
|
|
30
|
+
import { convertToModelMessages, type UIMessage } from "ai";
|
|
35
31
|
import { getWritable } from "workflow";
|
|
36
|
-
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./
|
|
37
|
-
import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
|
|
32
|
+
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";
|
|
38
33
|
|
|
39
34
|
export async function chat(messages: UIMessage[]) {
|
|
40
35
|
"use workflow";
|
|
41
36
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const agent = new DurableAgent({
|
|
45
|
-
model: "bedrock/claude-haiku-4-5-20251001-v1",
|
|
37
|
+
const agent = new WorkflowAgent({
|
|
38
|
+
model: "spacexai/grok-4.6",
|
|
46
39
|
instructions: FLIGHT_ASSISTANT_PROMPT,
|
|
47
40
|
tools: flightBookingTools,
|
|
48
41
|
});
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
messages: await convertToModelMessages(messages),
|
|
52
|
-
writable,
|
|
43
|
+
return agent.stream({
|
|
44
|
+
messages: await convertToModelMessages(messages),
|
|
45
|
+
writable: getWritable<ModelCallStreamPart>(),
|
|
53
46
|
});
|
|
54
47
|
}
|
|
55
48
|
```
|
|
56
49
|
|
|
57
|
-
|
|
50
|
+
### API route
|
|
58
51
|
|
|
59
|
-
|
|
52
|
+
Convert the durable model-call stream to AI SDK UI chunks at the response boundary:
|
|
60
53
|
|
|
61
54
|
```typescript title="app/api/chat/route.ts" lineNumbers
|
|
55
|
+
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
|
|
62
56
|
import { createUIMessageStreamResponse, type UIMessage } from "ai";
|
|
63
57
|
import { start } from "workflow/api";
|
|
64
58
|
import { chat } from "@/workflows/chat";
|
|
65
59
|
|
|
66
|
-
export async function POST(
|
|
67
|
-
const { messages }: { messages: UIMessage[] } = await
|
|
68
|
-
|
|
69
|
-
const run = await start(chat, [messages]); // [!code highlight]
|
|
60
|
+
export async function POST(request: Request) {
|
|
61
|
+
const { messages }: { messages: UIMessage[] } = await request.json();
|
|
62
|
+
const run = await start(chat, [messages]);
|
|
70
63
|
|
|
71
64
|
return createUIMessageStreamResponse({
|
|
72
|
-
stream: run.readable,
|
|
73
|
-
headers: {
|
|
74
|
-
"x-workflow-run-id": run.runId, // [!code highlight] For stream reconnection
|
|
75
|
-
},
|
|
65
|
+
stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()),
|
|
66
|
+
headers: { "x-workflow-run-id": run.runId },
|
|
76
67
|
});
|
|
77
68
|
}
|
|
78
69
|
```
|
|
79
70
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<Tab value="Client">
|
|
71
|
+
### Client
|
|
83
72
|
|
|
84
|
-
|
|
73
|
+
`WorkflowChatTransport` reconnects when the HTTP connection ends before the workflow stream finishes:
|
|
85
74
|
|
|
86
|
-
```
|
|
75
|
+
```tsx title="app/chat.tsx" lineNumbers
|
|
87
76
|
"use client";
|
|
88
77
|
|
|
89
78
|
import { useChat } from "@ai-sdk/react";
|
|
90
|
-
import { WorkflowChatTransport } from "@ai-sdk/workflow";
|
|
91
|
-
import { useParams } from "next/navigation";
|
|
79
|
+
import { WorkflowChatTransport } from "@ai-sdk/workflow";
|
|
92
80
|
import { useMemo } from "react";
|
|
93
81
|
|
|
94
|
-
// Fetch existing messages from your backend
|
|
95
|
-
async function getMessages(sessionId: string) { // [!code highlight]
|
|
96
|
-
const res = await fetch(`/api/chats/${sessionId}/messages`); // [!code highlight]
|
|
97
|
-
return res.json(); // [!code highlight]
|
|
98
|
-
} // [!code highlight]
|
|
99
|
-
|
|
100
82
|
export function Chat({ initialMessages }) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Persist the updated messages to the chat session // [!code highlight]
|
|
109
|
-
await fetch(`/api/chats/${sessionId}/messages`, { // [!code highlight]
|
|
110
|
-
method: "PUT", // [!code highlight]
|
|
111
|
-
headers: { "Content-Type": "application/json" }, // [!code highlight]
|
|
112
|
-
body: JSON.stringify({ messages }), // [!code highlight]
|
|
113
|
-
}); // [!code highlight]
|
|
114
|
-
}, // [!code highlight]
|
|
115
|
-
}), // [!code highlight]
|
|
116
|
-
[sessionId] // [!code highlight]
|
|
117
|
-
); // [!code highlight]
|
|
118
|
-
|
|
119
|
-
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
|
120
|
-
initialMessages, // [!code highlight] Loaded via getMessages(sessionId)
|
|
121
|
-
transport, // [!code highlight]
|
|
83
|
+
const transport = useMemo(
|
|
84
|
+
() => new WorkflowChatTransport({ api: "/api/chat" }),
|
|
85
|
+
[]
|
|
86
|
+
);
|
|
87
|
+
const { messages, sendMessage } = useChat({
|
|
88
|
+
messages: initialMessages,
|
|
89
|
+
transport,
|
|
122
90
|
});
|
|
123
91
|
|
|
124
|
-
|
|
125
|
-
<form onSubmit={handleSubmit}>
|
|
126
|
-
{/* ... render messages ... */}
|
|
127
|
-
<input value={input} onChange={handleInputChange} />
|
|
128
|
-
</form>
|
|
129
|
-
);
|
|
92
|
+
// Render messages and call sendMessage({ text }) from your form.
|
|
130
93
|
}
|
|
131
94
|
```
|
|
132
95
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
</Tabs>
|
|
136
|
-
|
|
137
|
-
This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide.
|
|
138
|
-
|
|
139
|
-
In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to a user-managed database.
|
|
96
|
+
Persist `UIMessage[]` in your application database. `WorkflowAgent.stream()` returns `ModelMessage[]`, but there is no general conversion from model messages back to UI messages with all UI metadata intact.
|
|
140
97
|
|
|
141
|
-
|
|
98
|
+
Use the single-turn pattern when:
|
|
142
99
|
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
-
|
|
100
|
+
- Your application already owns chat history
|
|
101
|
+
- Each turn should run on the latest deployment
|
|
102
|
+
- You want a simple request-to-run mapping
|
|
103
|
+
- Approval responses or client-side tool results arrive as another message turn
|
|
146
104
|
|
|
147
105
|
## Multi-turn workflows
|
|
148
106
|
|
|
149
|
-
A single workflow
|
|
107
|
+
A single workflow run can own the model-message history for the whole session. It waits on a Hook between turns, and external callers resume the Hook with the next message. The workflow run ID becomes the session identifier.
|
|
150
108
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<Tab value="Workflow">
|
|
109
|
+
```typescript title="workflows/chat-session.ts" lineNumbers
|
|
110
|
+
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
|
|
111
|
+
import { type ModelMessage } from "ai";
|
|
112
|
+
import { defineHook, getWorkflowMetadata, getWritable } from "workflow";
|
|
113
|
+
import { z } from "zod";
|
|
114
|
+
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";
|
|
158
115
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
type UIMessageChunk,
|
|
163
|
-
type UIMessage,
|
|
164
|
-
type ModelMessage,
|
|
165
|
-
} from "ai";
|
|
166
|
-
import { DurableAgent } from "@workflow/ai/agent";
|
|
167
|
-
import { getWritable, getWorkflowMetadata } from "workflow";
|
|
168
|
-
import { chatMessageHook } from "./hooks/chat-message";
|
|
169
|
-
import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
|
|
170
|
-
import { writeUserMessageMarker, writeStreamClose } from "./steps/writer"; // [!code highlight]
|
|
116
|
+
export const chatMessageHook = defineHook({
|
|
117
|
+
schema: z.object({ message: z.string() }),
|
|
118
|
+
});
|
|
171
119
|
|
|
172
|
-
export async function
|
|
120
|
+
export async function chatSession(initialMessages: ModelMessage[]) {
|
|
173
121
|
"use workflow";
|
|
174
122
|
|
|
175
|
-
const { workflowRunId
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
// Write markers for initial user messages (for replay) // [!code highlight]
|
|
180
|
-
for (const msg of initialMessages) { // [!code highlight]
|
|
181
|
-
if (msg.role === "user") { // [!code highlight]
|
|
182
|
-
const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(""); // [!code highlight]
|
|
183
|
-
if (text) await writeUserMessageMarker(writable, text, msg.id); // [!code highlight]
|
|
184
|
-
} // [!code highlight]
|
|
185
|
-
} // [!code highlight]
|
|
123
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
124
|
+
const hook = chatMessageHook.create({ token: workflowRunId });
|
|
125
|
+
const writable = getWritable<ModelCallStreamPart>();
|
|
126
|
+
let messages = [...initialMessages];
|
|
186
127
|
|
|
187
|
-
const agent = new
|
|
188
|
-
model: "
|
|
128
|
+
const agent = new WorkflowAgent({
|
|
129
|
+
model: "spacexai/grok-4.6",
|
|
189
130
|
instructions: FLIGHT_ASSISTANT_PROMPT,
|
|
190
131
|
tools: flightBookingTools,
|
|
191
132
|
});
|
|
192
133
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let turnNumber = 0;
|
|
196
|
-
|
|
197
|
-
while (true) {
|
|
198
|
-
turnNumber++;
|
|
134
|
+
const maxTurns = 100;
|
|
135
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
199
136
|
const result = await agent.stream({
|
|
200
137
|
messages,
|
|
201
138
|
writable,
|
|
202
|
-
preventClose: true,
|
|
203
|
-
sendStart: turnNumber === 1,
|
|
139
|
+
preventClose: true,
|
|
204
140
|
sendFinish: false,
|
|
205
141
|
});
|
|
206
|
-
messages
|
|
142
|
+
messages = result.messages;
|
|
207
143
|
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
if (followUp === "/done") break;
|
|
144
|
+
// Do not accept a follow-up that this run has no remaining turn to process.
|
|
145
|
+
if (turn === maxTurns - 1) break;
|
|
211
146
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
messages.push({ role: "user", content: followUp });
|
|
147
|
+
const { message } = await hook;
|
|
148
|
+
if (message === "/done") break;
|
|
149
|
+
messages = [...messages, { role: "user", content: message }];
|
|
216
150
|
}
|
|
217
151
|
|
|
218
|
-
await writeStreamClose(writable); // [!code highlight]
|
|
219
152
|
return { messages };
|
|
220
153
|
}
|
|
221
154
|
```
|
|
222
155
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
```typescript title="workflows/chat/steps/writer.ts" lineNumbers
|
|
226
|
-
import type { UIMessageChunk } from "ai";
|
|
227
|
-
|
|
228
|
-
export async function writeUserMessageMarker( // [!code highlight]
|
|
229
|
-
writable: WritableStream<UIMessageChunk>,
|
|
230
|
-
content: string,
|
|
231
|
-
messageId: string
|
|
232
|
-
) {
|
|
233
|
-
"use step"; // [!code highlight]
|
|
234
|
-
const writer = writable.getWriter();
|
|
235
|
-
try {
|
|
236
|
-
await writer.write({
|
|
237
|
-
type: "data-workflow", // [!code highlight]
|
|
238
|
-
data: { type: "user-message", id: messageId, content, timestamp: Date.now() }, // [!code highlight]
|
|
239
|
-
} as UIMessageChunk);
|
|
240
|
-
} finally {
|
|
241
|
-
writer.releaseLock();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export async function writeStreamClose(writable: WritableStream<UIMessageChunk>) {
|
|
246
|
-
const writer = writable.getWriter();
|
|
247
|
-
await writer.write({ type: "finish" });
|
|
248
|
-
await writer.close();
|
|
249
|
-
}
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
</Tab>
|
|
156
|
+
Create the Hook once, outside the loop. Recreating the same token on every turn causes a Hook conflict. Intermediate turns use `preventClose: true` with `sendFinish: false`; the workflow closes the durable stream and emits the final UI `finish` when the run returns.
|
|
253
157
|
|
|
254
|
-
|
|
158
|
+
### Start and resume the session
|
|
255
159
|
|
|
256
|
-
Use
|
|
160
|
+
Use one route to create the run and a second route to deliver follow-up messages:
|
|
257
161
|
|
|
258
162
|
```typescript title="app/api/chat/route.ts" lineNumbers
|
|
259
|
-
import {
|
|
163
|
+
import { convertToModelMessages, type UIMessage } from "ai";
|
|
260
164
|
import { start } from "workflow/api";
|
|
261
|
-
import {
|
|
262
|
-
|
|
263
|
-
export async function POST(req: Request) {
|
|
264
|
-
const { initialMessage }: { initialMessage: UIMessage } = await req.json();
|
|
265
|
-
|
|
266
|
-
const run = await start(chat, [[initialMessage]]); // [!code highlight]
|
|
165
|
+
import { chatSession } from "@/workflows/chat-session";
|
|
267
166
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
},
|
|
273
|
-
});
|
|
167
|
+
export async function POST(request: Request) {
|
|
168
|
+
const { messages }: { messages: UIMessage[] } = await request.json();
|
|
169
|
+
const run = await start(chatSession, [await convertToModelMessages(messages)]);
|
|
170
|
+
return Response.json({ runId: run.runId });
|
|
274
171
|
}
|
|
275
172
|
```
|
|
276
173
|
|
|
277
|
-
```typescript title="app/api/chat/[
|
|
278
|
-
import { chatMessageHook } from "@/workflows/chat
|
|
174
|
+
```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers
|
|
175
|
+
import { chatMessageHook } from "@/workflows/chat-session";
|
|
279
176
|
|
|
280
177
|
export async function POST(
|
|
281
|
-
|
|
282
|
-
{ params }: { params: Promise<{
|
|
178
|
+
request: Request,
|
|
179
|
+
{ params }: { params: Promise<{ runId: string }> }
|
|
283
180
|
) {
|
|
284
|
-
const {
|
|
285
|
-
const { message } = await
|
|
286
|
-
|
|
287
|
-
// Resume the hook using the workflow run ID // [!code highlight]
|
|
288
|
-
await chatMessageHook.resume(runId, { message }); // [!code highlight]
|
|
289
|
-
|
|
181
|
+
const { runId } = await params;
|
|
182
|
+
const { message }: { message: string } = await request.json();
|
|
183
|
+
await chatMessageHook.resume(runId, { message });
|
|
290
184
|
return Response.json({ success: true });
|
|
291
185
|
}
|
|
292
186
|
```
|
|
293
187
|
|
|
294
|
-
|
|
188
|
+
### Reconnect to the stream
|
|
189
|
+
|
|
190
|
+
`WorkflowChatTransport` counts transformed UI chunks, while the durable stream stores raw `ModelCallStreamPart` values. Always replay the raw stream from index `0` and apply the UI cursor in `createModelCallToUIChunkTransform()`:
|
|
191
|
+
|
|
192
|
+
{/* @skip-typecheck: requires AI SDK 7 and @ai-sdk/workflow */}
|
|
193
|
+
```typescript title="app/api/chat/[runId]/stream/route.ts" lineNumbers
|
|
194
|
+
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
|
|
295
195
|
import { createUIMessageStreamResponse } from "ai";
|
|
296
196
|
import { getRun } from "workflow/api";
|
|
297
197
|
|
|
298
198
|
export async function GET(
|
|
299
199
|
request: Request,
|
|
300
|
-
{ params }: { params: Promise<{
|
|
200
|
+
{ params }: { params: Promise<{ runId: string }> }
|
|
301
201
|
) {
|
|
302
|
-
const {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const run = getRun(id); // [!code highlight]
|
|
307
|
-
const stream = run.getReadable({ // [!code highlight]
|
|
308
|
-
startIndex: startIndex ? parseInt(startIndex, 10) : undefined, // [!code highlight]
|
|
309
|
-
}); // [!code highlight]
|
|
310
|
-
|
|
311
|
-
return createUIMessageStreamResponse({ stream });
|
|
312
|
-
}
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
</Tab>
|
|
316
|
-
|
|
317
|
-
<Tab value="Hook Definition">
|
|
318
|
-
|
|
319
|
-
```typescript title="workflows/chat/hooks/chat-message.ts" lineNumbers
|
|
320
|
-
import { defineHook } from "workflow";
|
|
321
|
-
import { z } from "zod";
|
|
322
|
-
|
|
323
|
-
export const chatMessageHook = defineHook({
|
|
324
|
-
schema: z.object({
|
|
325
|
-
message: z.string(),
|
|
326
|
-
}),
|
|
327
|
-
});
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
</Tab>
|
|
331
|
-
|
|
332
|
-
<Tab value="Client Hook">
|
|
333
|
-
|
|
334
|
-
A custom hook wraps `useChat` to manage the multi-turn session. It handles:
|
|
335
|
-
|
|
336
|
-
- Routing between the initial message endpoint and follow-up endpoint
|
|
337
|
-
- Reconstructing user messages from stream markers for correct ordering on replay
|
|
338
|
-
|
|
339
|
-
```typescript title="hooks/use-multi-turn-chat.ts" lineNumbers
|
|
340
|
-
"use client";
|
|
202
|
+
const { runId } = await params;
|
|
203
|
+
const startIndex = Number(
|
|
204
|
+
new URL(request.url).searchParams.get("startIndex") ?? "0"
|
|
205
|
+
);
|
|
341
206
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
207
|
+
if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
|
|
208
|
+
return Response.json(
|
|
209
|
+
{ error: "startIndex must be a non-negative safe integer" },
|
|
210
|
+
{ status: 400 }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
346
213
|
|
|
347
|
-
const
|
|
214
|
+
const run = getRun(runId);
|
|
215
|
+
const stream = run
|
|
216
|
+
.getReadable({ startIndex: 0 })
|
|
217
|
+
.pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex }));
|
|
348
218
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
timestamp: number;
|
|
219
|
+
return createUIMessageStreamResponse({
|
|
220
|
+
stream,
|
|
221
|
+
headers: { "x-workflow-run-id": runId },
|
|
222
|
+
});
|
|
354
223
|
}
|
|
224
|
+
```
|
|
355
225
|
|
|
356
|
-
|
|
357
|
-
const [runId, setRunId] = useState<string | null>(null);
|
|
358
|
-
const [shouldResume, setShouldResume] = useState(false);
|
|
359
|
-
const userMessagesRef = useRef<Map<string, UIMessage>>(new Map());
|
|
360
|
-
|
|
361
|
-
// Check for existing session on mount // [!code highlight]
|
|
362
|
-
useEffect(() => {
|
|
363
|
-
const storedRunId = localStorage.getItem(STORAGE_KEY);
|
|
364
|
-
if (storedRunId) {
|
|
365
|
-
setRunId(storedRunId);
|
|
366
|
-
setShouldResume(true);
|
|
367
|
-
}
|
|
368
|
-
}, []);
|
|
369
|
-
|
|
370
|
-
const transport = useMemo(
|
|
371
|
-
() =>
|
|
372
|
-
new WorkflowChatTransport({
|
|
373
|
-
api: "/api/chat",
|
|
374
|
-
onChatSendMessage: (response) => {
|
|
375
|
-
const workflowRunId = response.headers.get("x-workflow-run-id");
|
|
376
|
-
if (workflowRunId) {
|
|
377
|
-
setRunId(workflowRunId);
|
|
378
|
-
localStorage.setItem(STORAGE_KEY, workflowRunId);
|
|
379
|
-
}
|
|
380
|
-
},
|
|
381
|
-
onChatEnd: () => {
|
|
382
|
-
setRunId(null);
|
|
383
|
-
localStorage.removeItem(STORAGE_KEY);
|
|
384
|
-
userMessagesRef.current.clear();
|
|
385
|
-
},
|
|
386
|
-
prepareReconnectToStreamRequest: ({ api, ...rest }) => {
|
|
387
|
-
const storedRunId = localStorage.getItem(STORAGE_KEY);
|
|
388
|
-
if (!storedRunId) throw new Error("No active session");
|
|
389
|
-
return { ...rest, api: `/api/chat/${storedRunId}/stream` };
|
|
390
|
-
},
|
|
391
|
-
}),
|
|
392
|
-
[]
|
|
393
|
-
);
|
|
226
|
+
Persist `UIMessage[]` separately if the client must reconstruct user messages and UI metadata after a refresh. The workflow-owned `ModelMessage[]` history is the model's durable context, not a replacement for an application chat table.
|
|
394
227
|
|
|
395
|
-
|
|
396
|
-
useChat({ resume: shouldResume, transport });
|
|
397
|
-
|
|
398
|
-
// Reconstruct conversation order from stream markers // [!code highlight]
|
|
399
|
-
const messages = useMemo(() => { // [!code highlight]
|
|
400
|
-
const result: UIMessage[] = []; // [!code highlight]
|
|
401
|
-
const seenContent = new Set<string>(); // [!code highlight]
|
|
402
|
-
// [!code highlight]
|
|
403
|
-
// Collect content from optimistic user messages // [!code highlight]
|
|
404
|
-
for (const msg of rawMessages) { // [!code highlight]
|
|
405
|
-
if (msg.role === "user") { // [!code highlight]
|
|
406
|
-
const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(""); // [!code highlight]
|
|
407
|
-
if (text) seenContent.add(text); // [!code highlight]
|
|
408
|
-
} // [!code highlight]
|
|
409
|
-
} // [!code highlight]
|
|
410
|
-
// [!code highlight]
|
|
411
|
-
for (const msg of rawMessages) { // [!code highlight]
|
|
412
|
-
if (msg.role === "user") { // [!code highlight]
|
|
413
|
-
result.push(msg); // [!code highlight]
|
|
414
|
-
continue; // [!code highlight]
|
|
415
|
-
} // [!code highlight]
|
|
416
|
-
// [!code highlight]
|
|
417
|
-
if (msg.role === "assistant") { // [!code highlight]
|
|
418
|
-
// Process parts in order, splitting on user-message markers // [!code highlight]
|
|
419
|
-
let currentParts: typeof msg.parts = []; // [!code highlight]
|
|
420
|
-
let partIndex = 0; // [!code highlight]
|
|
421
|
-
// [!code highlight]
|
|
422
|
-
for (const part of msg.parts) { // [!code highlight]
|
|
423
|
-
if (part.type === "data-workflow" && "data" in part) { // [!code highlight]
|
|
424
|
-
const data = part.data as UserMessageData; // [!code highlight]
|
|
425
|
-
if (data?.type === "user-message") { // [!code highlight]
|
|
426
|
-
// Flush accumulated assistant parts // [!code highlight]
|
|
427
|
-
if (currentParts.length > 0) { // [!code highlight]
|
|
428
|
-
result.push({ ...msg, id: `${msg.id}-${partIndex++}`, parts: currentParts }); // [!code highlight]
|
|
429
|
-
currentParts = []; // [!code highlight]
|
|
430
|
-
} // [!code highlight]
|
|
431
|
-
// Add user message if not duplicate // [!code highlight]
|
|
432
|
-
if (!seenContent.has(data.content)) { // [!code highlight]
|
|
433
|
-
seenContent.add(data.content); // [!code highlight]
|
|
434
|
-
result.push({ id: data.id, role: "user", parts: [{ type: "text", text: data.content }] }); // [!code highlight]
|
|
435
|
-
} // [!code highlight]
|
|
436
|
-
continue; // [!code highlight]
|
|
437
|
-
} // [!code highlight]
|
|
438
|
-
} // [!code highlight]
|
|
439
|
-
currentParts.push(part); // [!code highlight]
|
|
440
|
-
} // [!code highlight]
|
|
441
|
-
// [!code highlight]
|
|
442
|
-
if (currentParts.length > 0) { // [!code highlight]
|
|
443
|
-
result.push({ ...msg, id: partIndex > 0 ? `${msg.id}-${partIndex}` : msg.id, parts: currentParts }); // [!code highlight]
|
|
444
|
-
} // [!code highlight]
|
|
445
|
-
} // [!code highlight]
|
|
446
|
-
} // [!code highlight]
|
|
447
|
-
return result; // [!code highlight]
|
|
448
|
-
}, [rawMessages]); // [!code highlight]
|
|
449
|
-
|
|
450
|
-
// Route messages to appropriate endpoint
|
|
451
|
-
const sendMessage = useCallback(
|
|
452
|
-
async (text: string) => {
|
|
453
|
-
if (runId) {
|
|
454
|
-
// Follow-up: send via hook resumption // [!code highlight]
|
|
455
|
-
await fetch(`/api/chat/${runId}`, {
|
|
456
|
-
method: "POST",
|
|
457
|
-
headers: { "Content-Type": "application/json" },
|
|
458
|
-
body: JSON.stringify({ message: text }),
|
|
459
|
-
});
|
|
460
|
-
} else {
|
|
461
|
-
// First message: start new workflow
|
|
462
|
-
await baseSendMessage({ text, metadata: { createdAt: Date.now() } });
|
|
463
|
-
}
|
|
464
|
-
},
|
|
465
|
-
[runId, baseSendMessage]
|
|
466
|
-
);
|
|
228
|
+
### Persist and display the full session
|
|
467
229
|
|
|
468
|
-
|
|
469
|
-
if (runId) {
|
|
470
|
-
await fetch(`/api/chat/${runId}`, {
|
|
471
|
-
method: "POST",
|
|
472
|
-
headers: { "Content-Type": "application/json" },
|
|
473
|
-
body: JSON.stringify({ message: "/done" }),
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
setRunId(null);
|
|
477
|
-
setShouldResume(false);
|
|
478
|
-
localStorage.removeItem(STORAGE_KEY);
|
|
479
|
-
userMessagesRef.current.clear();
|
|
480
|
-
setMessages([]);
|
|
481
|
-
}, [runId, setMessages]);
|
|
482
|
-
|
|
483
|
-
return { messages, status, runId, sendMessage, endSession, stop };
|
|
484
|
-
}
|
|
485
|
-
```
|
|
230
|
+
A multi-turn chat has three related records with different responsibilities:
|
|
486
231
|
|
|
487
|
-
|
|
232
|
+
1. **Workflow model history**: `ModelMessage[]` is the durable context sent back to the model on each turn.
|
|
233
|
+
2. **Workflow run stream**: `ModelCallStreamPart` values contain durable model and tool output for live delivery and reconnection.
|
|
234
|
+
3. **Application chat history**: `UIMessage[]` preserves user messages, display metadata, attachments, and application-specific parts.
|
|
488
235
|
|
|
489
|
-
|
|
236
|
+
When the user sends the first message, start the run, store its run ID with the application chat record, and connect to the run stream. For each follow-up, optimistically add and persist the user `UIMessage`, then resume `chatMessageHook` with the corresponding text. After a refresh, load the persisted UI messages and reconnect through the stream route above using the last persisted UI chunk cursor. If you persist only user messages, replay model output from the beginning and merge by stable message IDs.
|
|
490
237
|
|
|
491
|
-
|
|
238
|
+
Do not treat the run stream as the only chat database. `WorkflowAgent` deliberately stores raw model-call parts, and user messages resumed through a Hook are not automatically written to that output stream. Keeping the application history separate avoids synthetic stream markers and preserves UI information that cannot be reconstructed from `ModelMessage[]`.
|
|
492
239
|
|
|
493
|
-
|
|
240
|
+
<Callout type="info">
|
|
241
|
+
The reconnect route replays raw `ModelCallStreamPart` values from index `0` because raw parts and transformed UI chunks do not have matching indexes. `uiStartIndex` prevents already-delivered UI chunks from being sent to the client again, but the server still transforms the earlier raw history. For very long streams, split conversations into bounded runs until WorkflowAgent exposes a persisted raw-to-UI cursor mapping.
|
|
242
|
+
</Callout>
|
|
494
243
|
|
|
495
|
-
|
|
496
|
-
2. When a `user-message` marker is found, flush any accumulated assistant content and insert the user message.
|
|
497
|
-
3. Deduplicate against optimistic sends from the initial message.
|
|
244
|
+
Use the multi-turn pattern when:
|
|
498
245
|
|
|
499
|
-
|
|
246
|
+
- One workflow should own the session's model context
|
|
247
|
+
- Backend events or other users need to inject messages through Hooks
|
|
248
|
+
- Full-session tracing is more important than running every turn on the newest deployment
|
|
249
|
+
- The application is prepared to manage a long-lived run and stream cursor
|
|
500
250
|
|
|
501
251
|
## Choosing a pattern
|
|
502
252
|
|
|
503
|
-
| Consideration | Single-
|
|
253
|
+
| Consideration | Single-turn | Multi-turn |
|
|
504
254
|
|--------------|-------------|------------|
|
|
505
|
-
| State ownership | Client or
|
|
506
|
-
|
|
|
255
|
+
| State ownership | Client or application database | Workflow for model context; application database for UI history |
|
|
256
|
+
| Deployment version | Latest deployment per turn | Deployment that started the run |
|
|
257
|
+
| Message injection | Start another run | Resume a Hook |
|
|
507
258
|
| Workflow complexity | Lower | Higher |
|
|
508
|
-
| Workflow time horizon |
|
|
509
|
-
| Observability scope | Per
|
|
259
|
+
| Workflow time horizon | One model turn | Hours or longer |
|
|
260
|
+
| Observability scope | Per turn | Full session |
|
|
510
261
|
|
|
511
|
-
**Multi-turn
|
|
262
|
+
**Multi-turn works well for new durable sessions.** The workflow owns model context, accepts messages from users and backend systems through the same Hook, and provides one full-session trace.
|
|
512
263
|
|
|
513
|
-
**Single-turn works well when adapting existing
|
|
264
|
+
**Single-turn works well when adapting an existing architecture.** If the application already manages message state and you want to adopt durable agents incrementally, one workflow run per turn requires fewer lifecycle changes and always uses the latest deployment.
|
|
514
265
|
|
|
515
266
|
## Multiplayer chat sessions
|
|
516
267
|
|
|
517
|
-
The multi-turn pattern also
|
|
268
|
+
The multi-turn pattern also supports messages from system events, external services, and multiple users. Every source resumes the same Hook; the workflow queues those messages and processes them between model turns.
|
|
518
269
|
|
|
519
|
-
<Tabs items={['System
|
|
270
|
+
<Tabs items={['System event', 'External service', 'Multiple users']}>
|
|
520
271
|
|
|
521
|
-
<Tab value="System
|
|
272
|
+
<Tab value="System event">
|
|
522
273
|
|
|
523
|
-
|
|
274
|
+
Scheduled tasks, background jobs, or database triggers can inject updates into an active conversation:
|
|
524
275
|
|
|
525
276
|
```typescript title="app/api/internal/flight-update/route.ts" lineNumbers
|
|
526
|
-
import { chatMessageHook } from "@/workflows/chat
|
|
277
|
+
import { chatMessageHook } from "@/workflows/chat-session";
|
|
527
278
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const { runId, flightNumber, newStatus } = await req.json();
|
|
279
|
+
export async function POST(request: Request) {
|
|
280
|
+
const { runId, flightNumber, newStatus } = await request.json();
|
|
531
281
|
|
|
532
|
-
await chatMessageHook.resume(runId, {
|
|
533
|
-
message: `[System] Flight ${flightNumber} status updated: ${newStatus}`,
|
|
534
|
-
});
|
|
282
|
+
await chatMessageHook.resume(runId, {
|
|
283
|
+
message: `[System] Flight ${flightNumber} status updated: ${newStatus}`,
|
|
284
|
+
});
|
|
535
285
|
|
|
536
286
|
return Response.json({ success: true });
|
|
537
287
|
}
|
|
@@ -539,20 +289,20 @@ export async function POST(req: Request) {
|
|
|
539
289
|
|
|
540
290
|
</Tab>
|
|
541
291
|
|
|
542
|
-
<Tab value="External
|
|
292
|
+
<Tab value="External service">
|
|
543
293
|
|
|
544
|
-
|
|
294
|
+
A third-party webhook can notify the conversation about an external event:
|
|
545
295
|
|
|
546
296
|
```typescript title="app/api/webhooks/payment/route.ts" lineNumbers
|
|
547
|
-
import { chatMessageHook } from "@/workflows/chat
|
|
297
|
+
import { chatMessageHook } from "@/workflows/chat-session";
|
|
548
298
|
|
|
549
|
-
export async function POST(
|
|
550
|
-
const { runId, paymentStatus, amount } = await
|
|
299
|
+
export async function POST(request: Request) {
|
|
300
|
+
const { runId, paymentStatus, amount } = await request.json();
|
|
551
301
|
|
|
552
302
|
if (paymentStatus === "succeeded") {
|
|
553
|
-
await chatMessageHook.resume(runId, {
|
|
554
|
-
message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed
|
|
555
|
-
});
|
|
303
|
+
await chatMessageHook.resume(runId, {
|
|
304
|
+
message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed.`,
|
|
305
|
+
});
|
|
556
306
|
}
|
|
557
307
|
|
|
558
308
|
return Response.json({ received: true });
|
|
@@ -561,38 +311,39 @@ export async function POST(req: Request) {
|
|
|
561
311
|
|
|
562
312
|
</Tab>
|
|
563
313
|
|
|
564
|
-
<Tab value="Multiple
|
|
314
|
+
<Tab value="Multiple users">
|
|
565
315
|
|
|
566
|
-
Multiple
|
|
316
|
+
Multiple authenticated users can participate in the same workflow-owned session. Include attribution when resuming the Hook:
|
|
567
317
|
|
|
568
|
-
```typescript title="app/api/chat/[
|
|
569
|
-
import { chatMessageHook } from "@/workflows/chat
|
|
318
|
+
```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers
|
|
319
|
+
import { chatMessageHook } from "@/workflows/chat-session";
|
|
570
320
|
import { getUser } from "@/lib/auth";
|
|
571
321
|
|
|
572
322
|
export async function POST(
|
|
573
|
-
|
|
574
|
-
{ params }: { params: Promise<{
|
|
323
|
+
request: Request,
|
|
324
|
+
{ params }: { params: Promise<{ runId: string }> }
|
|
575
325
|
) {
|
|
576
|
-
const {
|
|
577
|
-
const { message } = await
|
|
578
|
-
const user = await getUser(
|
|
326
|
+
const { runId } = await params;
|
|
327
|
+
const { message } = await request.json();
|
|
328
|
+
const user = await getUser(request);
|
|
579
329
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}); // [!code highlight]
|
|
330
|
+
await chatMessageHook.resume(runId, {
|
|
331
|
+
message: `[${user.name}] ${message}`,
|
|
332
|
+
});
|
|
584
333
|
|
|
585
334
|
return Response.json({ success: true });
|
|
586
335
|
}
|
|
587
336
|
```
|
|
588
337
|
|
|
338
|
+
To preserve structured attribution across refreshes, persist the corresponding `UIMessage` using the application-history approach described above.
|
|
339
|
+
|
|
589
340
|
</Tab>
|
|
590
341
|
|
|
591
342
|
</Tabs>
|
|
592
343
|
|
|
593
344
|
## Related documentation
|
|
594
345
|
|
|
595
|
-
- [Building Durable AI Agents](/docs/ai): Foundation guide for
|
|
596
|
-
- [Message Queueing](/docs/ai/message-queueing):
|
|
597
|
-
- [
|
|
346
|
+
- [Building Durable AI Agents](/docs/ai): Foundation guide for WorkflowAgent
|
|
347
|
+
- [Message Queueing](/docs/ai/message-queueing): Inject messages between model-call steps
|
|
348
|
+
- [Resumable Streams](/docs/ai/resumable-streams): Reconnect to durable output
|
|
598
349
|
- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK API for durable, resumable agents
|