@trigger.dev/sdk 4.6.4 → 4.7.0

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.
Files changed (119) hide show
  1. package/dist/commonjs/v3/ai.d.ts +5 -2
  2. package/dist/commonjs/v3/ai.js +189 -266
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat-client.js +7 -0
  5. package/dist/commonjs/v3/chat-client.js.map +1 -1
  6. package/dist/commonjs/v3/chat-server.d.ts +1 -0
  7. package/dist/commonjs/v3/chat-server.js +8 -0
  8. package/dist/commonjs/v3/chat-server.js.map +1 -1
  9. package/dist/commonjs/v3/chat.d.ts +28 -4
  10. package/dist/commonjs/v3/chat.js +41 -9
  11. package/dist/commonjs/v3/chat.js.map +1 -1
  12. package/dist/commonjs/v3/chatRouteWait.d.ts +21 -0
  13. package/dist/commonjs/v3/chatRouteWait.js +43 -0
  14. package/dist/commonjs/v3/chatRouteWait.js.map +1 -0
  15. package/dist/commonjs/v3/compactionResponse.js +5 -0
  16. package/dist/commonjs/v3/compactionResponse.js.map +1 -1
  17. package/dist/commonjs/v3/concurrency-shared.d.ts +13 -0
  18. package/dist/commonjs/v3/concurrency-shared.js +35 -0
  19. package/dist/commonjs/v3/concurrency-shared.js.map +1 -0
  20. package/dist/commonjs/v3/concurrencyLimits.d.ts +73 -0
  21. package/dist/commonjs/v3/concurrencyLimits.js +166 -0
  22. package/dist/commonjs/v3/concurrencyLimits.js.map +1 -0
  23. package/dist/commonjs/v3/index.d.ts +2 -1
  24. package/dist/commonjs/v3/index.js +3 -1
  25. package/dist/commonjs/v3/index.js.map +1 -1
  26. package/dist/commonjs/v3/managedChatResponse.d.ts +44 -0
  27. package/dist/commonjs/v3/managedChatResponse.js +233 -0
  28. package/dist/commonjs/v3/managedChatResponse.js.map +1 -0
  29. package/dist/commonjs/v3/queues.d.ts +31 -0
  30. package/dist/commonjs/v3/queues.js +31 -0
  31. package/dist/commonjs/v3/queues.js.map +1 -1
  32. package/dist/commonjs/v3/shared.d.ts +18 -1
  33. package/dist/commonjs/v3/shared.js +137 -47
  34. package/dist/commonjs/v3/shared.js.map +1 -1
  35. package/dist/commonjs/v3/steeringContext.d.ts +41 -0
  36. package/dist/commonjs/v3/steeringContext.js +118 -0
  37. package/dist/commonjs/v3/steeringContext.js.map +1 -0
  38. package/dist/commonjs/v3/transcriptStorage.d.ts +4 -1
  39. package/dist/commonjs/v3/transcriptStorage.js +51 -4
  40. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  41. package/dist/commonjs/version.js +1 -1
  42. package/dist/esm/v3/ai.d.ts +5 -2
  43. package/dist/esm/v3/ai.js +189 -266
  44. package/dist/esm/v3/ai.js.map +1 -1
  45. package/dist/esm/v3/chat-client.js +7 -0
  46. package/dist/esm/v3/chat-client.js.map +1 -1
  47. package/dist/esm/v3/chat-server.d.ts +1 -0
  48. package/dist/esm/v3/chat-server.js +8 -0
  49. package/dist/esm/v3/chat-server.js.map +1 -1
  50. package/dist/esm/v3/chat.d.ts +28 -4
  51. package/dist/esm/v3/chat.js +41 -9
  52. package/dist/esm/v3/chat.js.map +1 -1
  53. package/dist/esm/v3/chatRouteWait.d.ts +21 -0
  54. package/dist/esm/v3/chatRouteWait.js +40 -0
  55. package/dist/esm/v3/chatRouteWait.js.map +1 -0
  56. package/dist/esm/v3/compactionResponse.js +5 -0
  57. package/dist/esm/v3/compactionResponse.js.map +1 -1
  58. package/dist/esm/v3/concurrency-shared.d.ts +13 -0
  59. package/dist/esm/v3/concurrency-shared.js +31 -0
  60. package/dist/esm/v3/concurrency-shared.js.map +1 -0
  61. package/dist/esm/v3/concurrencyLimits.d.ts +73 -0
  62. package/dist/esm/v3/concurrencyLimits.js +158 -0
  63. package/dist/esm/v3/concurrencyLimits.js.map +1 -0
  64. package/dist/esm/v3/index.d.ts +2 -1
  65. package/dist/esm/v3/index.js +2 -1
  66. package/dist/esm/v3/index.js.map +1 -1
  67. package/dist/esm/v3/managedChatResponse.d.ts +44 -0
  68. package/dist/esm/v3/managedChatResponse.js +228 -0
  69. package/dist/esm/v3/managedChatResponse.js.map +1 -0
  70. package/dist/esm/v3/queues.d.ts +31 -0
  71. package/dist/esm/v3/queues.js +31 -0
  72. package/dist/esm/v3/queues.js.map +1 -1
  73. package/dist/esm/v3/shared.d.ts +18 -1
  74. package/dist/esm/v3/shared.js +136 -47
  75. package/dist/esm/v3/shared.js.map +1 -1
  76. package/dist/esm/v3/steeringContext.d.ts +41 -0
  77. package/dist/esm/v3/steeringContext.js +113 -0
  78. package/dist/esm/v3/steeringContext.js.map +1 -0
  79. package/dist/esm/v3/transcriptStorage.d.ts +4 -1
  80. package/dist/esm/v3/transcriptStorage.js +51 -4
  81. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  82. package/dist/esm/version.js +1 -1
  83. package/docs/ai-chat/client-protocol.mdx +3 -1
  84. package/docs/ai-chat/error-handling.mdx +44 -76
  85. package/docs/ai-chat/fast-starts.mdx +1 -1
  86. package/docs/ai-chat/frontend.mdx +27 -21
  87. package/docs/ai-chat/patterns/branching-conversations.mdx +95 -230
  88. package/docs/ai-chat/patterns/human-in-the-loop.mdx +166 -164
  89. package/docs/ai-chat/patterns/tool-result-auditing.mdx +28 -27
  90. package/docs/ai-chat/patterns/version-upgrades.mdx +4 -4
  91. package/docs/ai-chat/pending-messages.mdx +19 -5
  92. package/docs/ai-chat/quick-start.mdx +26 -20
  93. package/docs/ai-chat/reference.mdx +21 -3
  94. package/docs/ai-chat/sessions.mdx +1 -1
  95. package/docs/ai-chat/testing.mdx +16 -4
  96. package/docs/concurrency.mdx +384 -0
  97. package/docs/database-connections.mdx +3 -3
  98. package/docs/deploy-environment-variables.mdx +6 -0
  99. package/docs/deployment/atomic-deployment.mdx +416 -132
  100. package/docs/deployment/overview.mdx +2 -2
  101. package/docs/github-actions.mdx +2 -2
  102. package/docs/github-integration.mdx +2 -2
  103. package/docs/idempotency.mdx +43 -5
  104. package/docs/introduction.mdx +1 -1
  105. package/docs/limits.mdx +16 -6
  106. package/docs/observability/query.mdx +25 -0
  107. package/docs/queues.mdx +271 -0
  108. package/docs/reports.mdx +1 -1
  109. package/docs/runs/priority.mdx +2 -25
  110. package/docs/self-hosting/env/webapp.mdx +7 -0
  111. package/docs/tasks/overview.mdx +3 -5
  112. package/docs/troubleshooting-alerts.mdx +124 -1
  113. package/docs/troubleshooting.mdx +12 -0
  114. package/docs/vercel-integration.mdx +6 -7
  115. package/docs/versioning.mdx +1 -1
  116. package/docs/writing-tasks-introduction.mdx +2 -1
  117. package/package.json +2 -2
  118. package/docs/deployment/version-skew-protection.mdx +0 -492
  119. package/docs/queue-concurrency.mdx +0 -358
@@ -1,280 +1,145 @@
1
1
  ---
2
2
  title: "Branching conversations"
3
3
  sidebarTitle: "Branching conversations"
4
- description: "Build ChatGPT-style conversation trees with edit, regenerate, undo, and branch switching using hydrateMessages, chat.history, and actions."
4
+ description: "Persist an active conversation branch and use transcript storage and actions to switch the agent's context."
5
5
  ---
6
6
 
7
- Most chat UIs treat conversations as linear sequences. But real conversations branch — users edit previous messages, regenerate responses, undo exchanges, and explore alternative paths. This pattern shows how to build a branching conversation system using `hydrateMessages`, `chat.history`, and custom actions.
7
+ Keep an explicit active branch for each chat. Use your storage adapter's `loadContext` to give the model that branch, and use actions to fork or switch it.
8
8
 
9
- ## Data model
9
+ A fork copies the conversation up to a selected message. Later messages belong to that branch only. For example, one branch can keep Oslo as a destination while another changes it to Tokyo.
10
10
 
11
- The standard approach (used by ChatGPT, Open WebUI, LibreChat, and others) stores messages as a tree with parent pointers:
11
+ ## Store the active branch
12
12
 
13
- ```ts
14
- // Each message is a node in the tree
15
- type ChatNode = {
16
- id: string;
17
- chatId: string;
18
- parentId: string | null; // null for root
19
- role: "user" | "assistant";
20
- message: UIMessage; // the full AI SDK message
21
- createdAt: Date;
22
- };
23
- ```
13
+ Your database needs a chat record with an `activeBranchId`, plus messages for each branch. You can store each branch as a snapshot or use parent pointers to share message prefixes. Persist the selected branch explicitly; choosing the newest leaf loses the selection when someone switches to an older branch.
24
14
 
25
- A conversation is a tree of nodes. The **active branch** is resolved by walking from a leaf node up through `parentId` pointers to the root, then reversing:
15
+ Keep the transcript's `final` flags, opaque runtime state, and stream cursors. A partial assistant response must stay partial after a reload. See [transcript storage](/ai-chat/transcript-storage) for the adapter contract and pagination helpers.
26
16
 
27
- ```
28
- root
29
- ├── user: "Hello"
30
- │ └── assistant: "Hi there!"
31
- │ ├── user: "What's the weather?" ← branch A
32
- │ │ └── assistant: "It's sunny!"
33
- │ └── user: "Tell me a joke" ← branch B (active)
34
- │ └── assistant: "Why did the..."
35
- ```
17
+ The examples below use application helpers from `@/lib/branches`. Implement them against your database with these guarantees:
36
18
 
37
- Switching branches means changing which leaf is "active" — the same tree, different path.
19
+ | Helper | Behavior |
20
+ | --- | --- |
21
+ | `readActiveMessages(chatId)` | Return the selected branch in message order. |
22
+ | `forkBranch(chatId, messageId)` | Copy the active prefix through the selected message, create a branch, and select it in one transaction. Reject messages outside this chat. |
23
+ | `selectBranch(chatId, branchId)` | Select an existing branch belonging to this chat. |
24
+ | `branchStorage.load` | Return the active transcript, runtime state, final flags, and resume cursors. |
25
+ | `branchStorage.save` | Save the supplied transcript to the active branch and retain the supplied runtime state and cursors. |
38
26
 
39
- ## Backend setup
27
+ Make repeated saves idempotent by message ID. Serialize branch changes with transcript writes so a turn can't save its response into a different branch.
40
28
 
41
- ### Store: tree operations
29
+ ## Load the selected context
42
30
 
43
- Define helpers that read and write the node tree. Adapt to your database:
31
+ A storage adapter with `loadContext` owns the model's context on every turn and action. Include incoming user messages in the selected branch. Handle regeneration explicitly by removing trailing assistant messages before returning the context.
44
32
 
45
- ```ts
46
- // Resolve the active path: walk from leaf to root, reverse
47
- async function getActiveBranch(chatId: string): Promise<UIMessage[]> {
48
- const nodes = await db.chatNode.findMany({ where: { chatId } });
49
- const byId = new Map(nodes.map((n) => [n.id, n]));
33
+ ```ts lib/branch-storage.ts
34
+ import type { UIMessage } from "ai";
35
+ import type {
36
+ LoadContextEvent,
37
+ TranscriptScope,
38
+ TranscriptStorage,
39
+ } from "@trigger.dev/sdk/ai";
40
+ import { branchStorage, readActiveMessages } from "@/lib/branches";
50
41
 
51
- // Find active leaf (most recently created leaf node)
52
- const childIds = new Set(nodes.map((n) => n.parentId).filter(Boolean));
53
- const leaves = nodes.filter((n) => !childIds.has(n.id));
54
- const activeLeaf = leaves.sort((a, b) => b.createdAt - a.createdAt)[0];
55
- if (!activeLeaf) return [];
42
+ export const storage: TranscriptStorage = {
43
+ ...branchStorage,
44
+ async loadContext<T extends UIMessage>(
45
+ { chatId }: TranscriptScope,
46
+ event: LoadContextEvent<unknown, T>
47
+ ): Promise<T[]> {
48
+ const messages = [...(await readActiveMessages(chatId))] as T[];
56
49
 
57
- // Walk to root
58
- const path: UIMessage[] = [];
59
- let current: ChatNode | undefined = activeLeaf;
60
- while (current) {
61
- path.unshift(current.message);
62
- current = current.parentId ? byId.get(current.parentId) : undefined;
63
- }
64
- return path;
65
- }
50
+ if (event.trigger === "regenerate-message") {
51
+ while (messages.length && messages.at(-1)!.role !== "user") {
52
+ messages.pop();
53
+ }
54
+ }
66
55
 
67
- // Append a message as a child of the current leaf
68
- async function appendMessage(chatId: string, message: UIMessage): Promise<void> {
69
- const branch = await getActiveBranch(chatId);
70
- const parentId = branch.length > 0 ? branch[branch.length - 1]!.id : null;
56
+ for (const incoming of event.incomingMessages) {
57
+ if (incoming.role === "user" && !messages.some((m) => m.id === incoming.id)) {
58
+ messages.push(incoming);
59
+ }
60
+ }
71
61
 
72
- await db.chatNode.create({
73
- data: { id: message.id, chatId, parentId, role: message.role, message, createdAt: new Date() },
74
- });
75
- }
62
+ return messages;
63
+ },
64
+ };
76
65
  ```
77
66
 
78
- ### Agent: hydration + actions
67
+ This pattern supports plain user messages. For tools that wait for browser results or approval, retain the canonical assistant message in your returned context. The SDK merges incoming tool-result state into that message by ID.
68
+
69
+ `loadContext` also receives `previousMessages`, including any tail recovered from the session stream. If your database missed a save, reconcile that tail with the active branch before returning context. Recovery needs to preserve branch ownership; appending every recovered message to whichever branch is selected can mix conversations.
70
+
71
+ ## Fork and switch through actions
72
+
73
+ Perform branch changes in `onAction` and update `chat.history` to match the selected branch. Returning without `chat.turn()` completes the action without asking the model for a response. The runtime still calls `storage.save` with reason `action`.
79
74
 
80
- ```ts
75
+ ```ts trigger/chat.ts
81
76
  import { chat } from "@trigger.dev/sdk/ai";
82
- import { streamText, stepCountIs } from "ai";
83
77
  import { anthropic } from "@ai-sdk/anthropic";
84
78
  import { z } from "zod";
79
+ import { storage } from "@/lib/branch-storage";
80
+ import { forkBranch, selectBranch, readActiveMessages } from "@/lib/branches";
85
81
 
86
82
  export const myChat = chat.agent({
87
83
  id: "branching-chat",
88
-
89
- // Load the active branch from the DB on every turn.
90
- // The frontend's message array is ignored — the tree is the source of truth.
91
- hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
92
- if (trigger === "submit-message" && incomingMessages.length > 0) {
93
- await appendMessage(chatId, incomingMessages[incomingMessages.length - 1]!);
94
- }
95
- return getActiveBranch(chatId);
96
- },
97
-
84
+ storage,
98
85
  actionSchema: z.discriminatedUnion("type", [
99
- // Edit a previous user message — creates a sibling node in the tree
100
- z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
101
- // Switch to a different branch by selecting a leaf node
102
- z.object({ type: z.literal("switch-branch"), leafId: z.string() }),
103
- // Undo the last user + assistant exchange
104
- z.object({ type: z.literal("undo") }),
86
+ z.object({ type: z.literal("fork"), messageId: z.string() }),
87
+ z.object({ type: z.literal("switch"), branchId: z.string() }),
105
88
  ]),
106
-
107
- onAction: async ({ action, chatId }) => {
108
- switch (action.type) {
109
- case "edit": {
110
- // Find the original message's parent, create a sibling with new content
111
- const original = await db.chatNode.findUnique({ where: { id: action.messageId } });
112
- if (!original) break;
113
-
114
- const newId = generateId();
115
- await db.chatNode.create({
116
- data: {
117
- id: newId,
118
- chatId,
119
- parentId: original.parentId, // same parent = sibling
120
- role: "user",
121
- message: { id: newId, role: "user", parts: [{ type: "text", text: action.text }] },
122
- createdAt: new Date(),
123
- },
124
- });
125
- // Active branch now resolves through the new sibling (most recent leaf)
126
- break;
127
- }
128
-
129
- case "switch-branch": {
130
- // Mark this leaf as the most recently accessed so getActiveBranch picks it
131
- await db.chatNode.update({
132
- where: { id: action.leafId },
133
- data: { createdAt: new Date() },
134
- });
135
- break;
136
- }
137
-
138
- case "undo": {
139
- // Remove the last two nodes (user + assistant) from the active branch
140
- const branch = await getActiveBranch(chatId);
141
- if (branch.length >= 2) {
142
- const lastTwo = branch.slice(-2);
143
- await db.chatNode.deleteMany({
144
- where: { id: { in: lastTwo.map((m) => m.id) } },
145
- });
146
- }
147
- break;
148
- }
89
+ onAction: async ({ chatId, action }) => {
90
+ if (action.type === "fork") {
91
+ await forkBranch(chatId, action.messageId);
92
+ } else {
93
+ await selectBranch(chatId, action.branchId);
149
94
  }
150
95
 
151
- // Reload the (now modified) active branch into the accumulator
152
- const updated = await getActiveBranch(chatId);
153
- chat.history.set(updated);
154
- },
155
-
156
- onTurnComplete: async ({ chatId, responseMessage }) => {
157
- // Persist the assistant's response as a new node
158
- if (responseMessage) {
159
- await appendMessage(chatId, responseMessage);
160
- }
96
+ chat.history.set(await readActiveMessages(chatId));
161
97
  },
162
-
163
- run: async ({ messages, signal }) => {
98
+ run: async ({ messages, signal, streamText }) => {
164
99
  return streamText({
165
100
  model: anthropic("claude-sonnet-4-5"),
166
101
  messages,
167
102
  abortSignal: signal,
168
- stopWhen: stepCountIs(15),
169
103
  });
170
104
  },
171
105
  });
172
106
  ```
173
107
 
174
- ## Frontend
175
-
176
- ### Sending actions
177
-
178
- Wire up edit, undo, and branch switching to the transport:
179
-
180
- ```tsx
181
- function MessageActions({ message, chatId }: { message: UIMessage; chatId: string }) {
182
- const transport = useTransport();
183
- const [editing, setEditing] = useState(false);
184
- const [editText, setEditText] = useState("");
185
-
186
- if (message.role !== "user") return null;
187
-
188
- return (
189
- <div>
190
- {editing ? (
191
- <form onSubmit={() => {
192
- transport.sendAction(chatId, { type: "edit", messageId: message.id, text: editText });
193
- setEditing(false);
194
- }}>
195
- <input value={editText} onChange={(e) => setEditText(e.target.value)} />
196
- <button type="submit">Save</button>
197
- </form>
198
- ) : (
199
- <button onClick={() => { setEditText(getMessageText(message)); setEditing(true); }}>
200
- Edit
201
- </button>
202
- )}
203
- </div>
204
- );
205
- }
206
- ```
108
+ Wait for a response to finish before forking or switching. Start with forks at completed assistant messages. Forking inside an unresolved tool exchange needs a policy for pending tool calls and already-performed side effects.
207
109
 
208
- ### Branch navigation
209
-
210
- To show the `< 2/3 >` sibling switcher, query the tree for siblings at each fork point. This is a frontend concern — the backend exposes the data, the UI navigates it.
211
-
212
- ```tsx
213
- function BranchSwitcher({ message, chatId, siblings }: {
214
- message: UIMessage;
215
- chatId: string;
216
- siblings: { id: string; createdAt: string }[];
217
- }) {
218
- const transport = useTransport();
219
- if (siblings.length <= 1) return null;
220
-
221
- const currentIndex = siblings.findIndex((s) => s.id === message.id);
222
-
223
- return (
224
- <div>
225
- <button
226
- disabled={currentIndex === 0}
227
- onClick={() => {
228
- // Find the leaf of the previous sibling's subtree
229
- transport.sendAction(chatId, {
230
- type: "switch-branch",
231
- leafId: siblings[currentIndex - 1]!.id,
232
- });
233
- }}
234
- >
235
- &lt;
236
- </button>
237
- <span>{currentIndex + 1}/{siblings.length}</span>
238
- <button
239
- disabled={currentIndex === siblings.length - 1}
240
- onClick={() => {
241
- transport.sendAction(chatId, {
242
- type: "switch-branch",
243
- leafId: siblings[currentIndex + 1]!.id,
244
- });
245
- }}
246
- >
247
- &gt;
248
- </button>
249
- </div>
250
- );
251
- }
252
- ```
110
+ ## Update the frontend after the action
253
111
 
254
- <Note>
255
- The sibling data (which messages share the same parent) needs to come from your database — query it when loading the chat or include it as client data. The agent only returns the active branch via `hydrateMessages`.
256
- </Note>
112
+ Use `useChatActions` with `useChat`'s `sendMessage`. It consumes the action's response stream and keeps request state in the chat hook. After the action completes, reload the selected transcript so the visible messages match the next model call.
257
113
 
258
- ## How it works
114
+ ```tsx app/chat/BranchPicker.tsx
115
+ import { useChat } from "@ai-sdk/react";
116
+ import { useChatActions } from "@trigger.dev/sdk/chat/react";
117
+ import { loadTranscript } from "@/app/actions";
259
118
 
260
- | Operation | What happens |
261
- |-----------|-------------|
262
- | **Send message** | `hydrateMessages` appends the new message as a child of the current leaf, returns the active path |
263
- | **Edit message** | `onAction` creates a sibling node with the same parent. The new node becomes the latest leaf, so `hydrateMessages` resolves through it. LLM responds to the edited history |
264
- | **Regenerate** | Same as edit — create a new assistant sibling. The AI SDK's `regenerate()` handles this via `trigger: "regenerate-message"` |
265
- | **Undo** | `onAction` removes the last two nodes. `chat.history.set()` updates the accumulator. LLM responds to the earlier state |
266
- | **Switch branch** | `onAction` updates which leaf is "active". `hydrateMessages` loads the new path. LLM responds to the switched context |
119
+ // Inside your chat component, with its existing chatId and transport:
120
+ const { sendMessage, setMessages, status } = useChat({ id: chatId, transport });
121
+ const { sendAction } = useChatActions({ sendMessage });
122
+ const busy = status === "submitted" || status === "streaming";
123
+
124
+ async function switchBranch(branchId: string) {
125
+ await sendAction({ type: "switch", branchId });
126
+ const transcript = await loadTranscript({ chatId });
127
+ setMessages(transcript.messages);
128
+ }
129
+
130
+ <button disabled={busy} onClick={() => switchBranch(branch.id)}>
131
+ {branch.label}
132
+ </button>
133
+ ```
267
134
 
268
- ## Design notes
135
+ Authenticate `loadTranscript` and check ownership before reading the chat. Validate branch IDs on the server even when the frontend only offers branches from that chat. If you use `transport.sendAction` directly, consume its returned stream before sending another message.
269
136
 
270
- - **Messages are immutable** — edits create siblings, not mutations. This preserves full history for analytics and auditing.
271
- - **The tree lives in your database** — the agent loads a linear path from it via `hydrateMessages`. The agent itself doesn't know about the tree structure.
272
- - **`hydrateMessages` + `onAction` + `chat.history`** are the three primitives. Hydration loads the active path, actions modify the tree, and `chat.history.set()` syncs the accumulator after tree modifications.
273
- - **Frontend owns navigation** — the `< 2/3 >` UI, sibling queries, and branch switching triggers are client-side concerns. The backend just processes actions and returns responses.
137
+ ## Check branch isolation
274
138
 
275
- ## See also
139
+ 1. Tell the agent the destination is Oslo and wait for the response.
140
+ 2. Fork at that response, then change the destination to Tokyo.
141
+ 3. Switch to the original branch and ask for the destination. It should answer Oslo.
142
+ 4. Switch to the fork and ask again. It should answer Tokyo.
143
+ 5. Reload the page and repeat the checks. The selected branch and its messages should survive the reload.
276
144
 
277
- - [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — backend-controlled message history
278
- - [Actions](/ai-chat/actions) — custom actions with `actionSchema` and `onAction`
279
- - [`chat.history`](/ai-chat/backend#chat-history) — imperative history mutations
280
- - [Database persistence](/ai-chat/patterns/database-persistence) — basic persistence pattern (linear)
145
+ Also test regeneration, repeated storage saves, and continuation in a fresh run. An in-memory branch choice can pass the first four steps and still disappear when the worker restarts.