Skip to content

Commit 7aeef27

Browse files
committed
docs(ai-chat): use upsertIncomingMessage helper in hydrateMessages examples
1 parent 12d40ac commit 7aeef27

4 files changed

Lines changed: 44 additions & 46 deletions

File tree

docs/ai-chat/changelog.mdx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,26 @@ The per-turn merge now overlays only the tool-part state advances (`output-avail
1616

1717
### `hydrateMessages` upsert-by-id
1818

19-
If your `hydrateMessages` hook persists the incoming message, **upsert by id** — don't unconditionally push. HITL continuations ship the existing assistant's id with a slim payload; a blind `stored.push(newMsg)` duplicates the row in the chain you return, the merge updates the first match, and the slim duplicate hits `toModelMessages` with no `input`. The examples in [lifecycle hooks](/ai-chat/lifecycle-hooks#hydratemessages), [Database persistence](/ai-chat/patterns/database-persistence#alternative-hydratemessages), and [Persistence and replay](/ai-chat/patterns/persistence-and-replay) have all been updated.
19+
If your `hydrateMessages` hook persists the incoming message, **upsert by id** — don't unconditionally push. HITL continuations ship the existing assistant's id with a slim payload; a blind `stored.push(newMsg)` duplicates the row in the chain you return, the merge updates the first match, and the slim duplicate hits `toModelMessages` with no `input`.
20+
21+
A new `upsertIncomingMessage` helper is exported from `@trigger.dev/sdk/ai` to handle this for the common case:
22+
23+
```ts
24+
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
25+
26+
chat.agent({
27+
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
28+
const record = await db.chat.findUnique({ where: { id: chatId } });
29+
const stored = record?.messages ?? [];
30+
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
31+
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
32+
}
33+
return stored;
34+
},
35+
});
36+
```
37+
38+
The helper pushes fresh user messages, no-ops on HITL continuations (so the runtime can overlay the new tool-state advance), and skips on non-`submit-message` triggers. Returns `true` if it mutated `stored`. The examples in [lifecycle hooks](/ai-chat/lifecycle-hooks#hydratemessages), [Database persistence](/ai-chat/patterns/database-persistence#alternative-hydratemessages), and [Persistence and replay](/ai-chat/patterns/persistence-and-replay) have all been updated. Custom hydrate logic (branching, rollback, etc.) can still write the upsert by hand — the helper is a convenience for the common shape.
2039

2140
### `onValidateMessages` slim wire caveat
2241

docs/ai-chat/lifecycle-hooks.mdx

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -280,30 +280,19 @@ Use this when the backend should be the source of truth for message history: abu
280280
| `previousRunId` | `string \| undefined` | The previous run ID (if continuation) |
281281

282282
```ts
283+
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
284+
283285
export const myChat = chat.agent({
284286
id: "my-chat",
285287
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
286288
const record = await db.chat.findUnique({ where: { id: chatId } });
287289
const stored = record?.messages ?? [];
288290

289-
// Upsert the incoming message by id. On HITL continuations
290-
// (`addToolOutput` / `addToolApproveResponse`) the incoming wire
291-
// shares the id of an existing assistant in `stored` — `push`ing
292-
// unconditionally would duplicate the row. The runtime merges the
293-
// resolution onto the existing entry; new ids (typically a fresh
294-
// user message) get appended.
295-
if (trigger === "submit-message" && incomingMessages.length > 0) {
296-
const newMsg = incomingMessages[incomingMessages.length - 1]!;
297-
const existingIdx = newMsg.id
298-
? stored.findIndex((m) => m.id === newMsg.id)
299-
: -1;
300-
if (existingIdx === -1) {
301-
stored.push(newMsg);
302-
await db.chat.update({
303-
where: { id: chatId },
304-
data: { messages: stored },
305-
});
306-
}
291+
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
292+
await db.chat.update({
293+
where: { id: chatId },
294+
data: { messages: stored },
295+
});
307296
}
308297

309298
return stored;
@@ -314,6 +303,10 @@ export const myChat = chat.agent({
314303
});
315304
```
316305

306+
`upsertIncomingMessage` (exported from `@trigger.dev/sdk/ai`) handles the three cases that matter — fresh user messages get pushed, HITL continuations (`addToolOutput` / `addToolApproveResponse`) no-op because the incoming wire shares the existing assistant's id and the runtime overlays the new tool-state advance onto that entry, and non-`submit-message` triggers (`regenerate-message` / `action`) skip persistence. It returns `true` when it mutated `stored`, so the caller knows whether to persist.
307+
308+
If you need branching, rollback, or other custom hydrate logic, you can still write the upsert by hand — `upsertIncomingMessage` is a convenience for the common case, not the only supported shape.
309+
317310
**Lifecycle position:** `onValidateMessages`**`hydrateMessages`**`onChatStart` (chat's first message only) → `onTurnStart``run()`
318311

319312
After the hook returns, the runtime overlays the wire's tool-state advances (`output-available` / `output-error` / `approval-responded` / `output-denied`) onto matching hydrated entries by id. Everything else on the hydrated entry — text, reasoning, tool `input`, providerMetadata — stays put. This makes [tool approvals](/ai-chat/frontend#tool-approvals) and HITL `addToolOutput` continuations work transparently: ship a slim resolution on the wire, the agent merges the new state onto your DB-backed copy.

docs/ai-chat/patterns/database-persistence.mdx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -178,26 +178,20 @@ For apps that need the backend to be the single source of truth for message hist
178178
With hydration, the hook loads messages from your database on every turn. The frontend's messages are ignored (except for the new user message, which arrives in `incomingMessages`):
179179

180180
```ts
181+
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
182+
181183
export const myChat = chat.agent({
182184
id: "my-chat",
183185
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
184186
const record = await db.chat.findUnique({ where: { id: chatId } });
185187
const stored = record?.messages ?? [];
186188

187-
// Upsert by id. HITL continuations (addToolOutput /
188-
// addToolApproveResponse) ship the existing assistant's id with a
189-
// slim payload — push-without-check duplicates the row, the
190-
// runtime merges only the first match, and the duplicate slim copy
191-
// hits `toModelMessages` with no `input`.
192-
if (trigger === "submit-message" && incomingMessages.length > 0) {
193-
const newMsg = incomingMessages[incomingMessages.length - 1]!;
194-
const existingIdx = newMsg.id
195-
? stored.findIndex((m) => m.id === newMsg.id)
196-
: -1;
197-
if (existingIdx === -1) {
198-
stored.push(newMsg);
199-
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
200-
}
189+
// `upsertIncomingMessage` pushes a fresh user message and no-ops
190+
// on HITL continuations (the runtime overlays the new tool-state
191+
// advance onto the existing entry). See lifecycle hooks for the
192+
// full pattern: /ai-chat/lifecycle-hooks#hydratemessages
193+
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
194+
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
201195
}
202196

203197
return stored;

docs/ai-chat/patterns/persistence-and-replay.mdx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,26 +131,18 @@ If `onAction` mutates `chat.history.*` and then the run crashes before the next
131131
When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.
132132

133133
```ts
134-
import { chat } from "@trigger.dev/sdk/ai";
134+
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
135135
import { db } from "@/lib/db";
136136

137137
export const myChat = chat.agent({
138138
id: "my-chat",
139139
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
140140
const stored = (await db.chat.findUnique({ where: { id: chatId } }))?.messages ?? [];
141141

142-
// Upsert by id — HITL continuations ship the existing assistant's
143-
// id with a slim payload; the runtime overlays the new state.
144-
// See lifecycle-hooks for the full pattern + rationale.
145-
if (trigger === "submit-message" && incomingMessages.length > 0) {
146-
const newMsg = incomingMessages[0]!;
147-
const existingIdx = newMsg.id
148-
? stored.findIndex((m) => m.id === newMsg.id)
149-
: -1;
150-
if (existingIdx === -1) {
151-
stored.push(newMsg);
152-
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
153-
}
142+
// See lifecycle-hooks for the full upsert pattern + rationale:
143+
// /ai-chat/lifecycle-hooks#hydratemessages
144+
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
145+
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
154146
}
155147

156148
return stored;

0 commit comments

Comments
 (0)