diff --git a/docs/ai-chat/fast-starts.mdx b/docs/ai-chat/fast-starts.mdx index 7310988964..6910dbb8b3 100644 --- a/docs/ai-chat/fast-starts.mdx +++ b/docs/ai-chat/fast-starts.mdx @@ -524,8 +524,27 @@ The handler keeps the SSE response open until the agent run signals turn-complet | Tool execution runs in | Trigger.dev agent run | Trigger.dev agent run | | Step 2+ LLM call runs in | Trigger.dev agent run | Trigger.dev agent run | | `onChatStart` / `onTurnStart` fire | After handover signal arrives | Normally | +| `hydrateMessages` fires (if registered) | After handover, with the first-turn history as `incomingMessages` | Normally | | `onTurnComplete` fires | After turn finishes (handover) or skipped (handover-skip) | Normally | +### Persistence and the handover contract + +A head-start turn persists exactly like a normal turn — the handover machinery is invisible to your hooks. The guarantees: + +- **One stable assistant `messageId` across the whole turn.** The route handler generates the id, the handover signal carries it to the agent, and the agent's step 2+ stream reuses it — so the browser merges step 1 and step 2+ into a single assistant message, and you can merge-by-id when persisting. +- **`onTurnComplete` is the canonical persistence point**, same as any turn. It carries the full assistant message under that one id: step-1 text, reasoning, and tool calls plus step-2+ tool results and text. The [database persistence](/ai-chat/patterns/database-persistence) patterns apply unchanged. +- **Reasoning parts survive the handover.** When step 1 runs on an extended-thinking model, the reasoning streamed by your route handler lands in the durable session history (and `onTurnComplete`) under the same `messageId`, with provider metadata intact — Anthropic thinking signatures survive a replay back to the model. Step-2 reasoning appends to the same message rather than replacing it. + +#### With `hydrateMessages` + +Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages). On the first turn, the hook receives the route handler's first-turn history as `incomingMessages` — the canonical upsert-and-return pattern persists the user message exactly as it would on a direct-trigger turn. The runtime splices the warm handler's partial assistant onto your hydrated chain after the hook returns, deduplicated by the assistant `messageId`, so your hook never needs to include the in-flight partial. + + + **Hydrate hooks must upsert their conversation row, not update it.** Head-start turns skip preload entirely, so row-creating hooks (`onPreload`, or an `onChatStart` create) have not run when `hydrateMessages` first fires. A bare `UPDATE` against a missing row throws and errors the turn. + + +Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders. + ### The `chat.headStart` API ```ts diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx index c327374e05..6089d9d8b0 100644 --- a/docs/ai-chat/lifecycle-hooks.mdx +++ b/docs/ai-chat/lifecycle-hooks.mdx @@ -273,7 +273,7 @@ Use this when the backend should be the source of truth for message history: abu | `chatId` | `string` | Chat session ID | | `turn` | `number` | Turn number (0-indexed) | | `trigger` | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn | -| `incomingMessages` | `UIMessage[]` | Validated wire messages from the frontend — 0-or-1-length (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses) | +| `incomingMessages` | `UIMessage[]` | Validated incoming messages for this turn. Usually 0-or-1 (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses). On a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, this can contain the route handler's first-turn history. | | `previousMessages` | `UIMessage[]` | Accumulated UI messages before this turn (`[]` on turn 0) | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | | `continuation` | `boolean` | Whether this run is continuing an existing chat | @@ -289,9 +289,12 @@ export const myChat = chat.agent({ const stored = record?.messages ?? []; if (upsertIncomingMessage(stored, { trigger, incomingMessages })) { - await db.chat.update({ + // Upsert, not update: on a head-start first turn no preload ran, + // so the row may not exist yet when this hook fires. + await db.chat.upsert({ where: { id: chatId }, - data: { messages: stored }, + create: { id: chatId, messages: stored }, + update: { messages: stored }, }); } @@ -320,7 +323,7 @@ After the hook returns, the runtime overlays the wire's tool-state advances (`ou - `incomingMessages` is **0-or-1-length** consistently. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array and the loop runs zero or one times. + `incomingMessages` is **usually 0-or-1-length**. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. The exception is a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, where it carries the route handler's first-turn history. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array rather than assuming a single element. ## onTurnStart diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx index fae1e47809..b9d56ab6c7 100644 --- a/docs/ai-chat/patterns/database-persistence.mdx +++ b/docs/ai-chat/patterns/database-persistence.mdx @@ -191,7 +191,13 @@ export const myChat = chat.agent({ // advance onto the existing entry). See lifecycle hooks for the // full pattern: /ai-chat/lifecycle-hooks#hydratemessages if (upsertIncomingMessage(stored, { trigger, incomingMessages })) { - await db.chat.update({ where: { id: chatId }, data: { messages: stored } }); + // Upsert, not update: on a head-start first turn no preload ran, + // so the row may not exist yet when this hook fires. + await db.chat.upsert({ + where: { id: chatId }, + create: { id: chatId, messages: stored }, + update: { messages: stored }, + }); } return stored; @@ -217,6 +223,8 @@ export const myChat = chat.agent({ This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place. +Hydration composes with [Head Start](/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row. + ## Design notes - **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity. diff --git a/docs/ai-chat/patterns/persistence-and-replay.mdx b/docs/ai-chat/patterns/persistence-and-replay.mdx index 4e1bdf4084..2af9cadfc9 100644 --- a/docs/ai-chat/patterns/persistence-and-replay.mdx +++ b/docs/ai-chat/patterns/persistence-and-replay.mdx @@ -142,7 +142,13 @@ export const myChat = chat.agent({ // See lifecycle-hooks for the full upsert pattern + rationale: // /ai-chat/lifecycle-hooks#hydratemessages if (upsertIncomingMessage(stored, { trigger, incomingMessages })) { - await db.chat.update({ where: { id: chatId }, data: { messages: stored } }); + // Upsert, not update: head-start first turns run without a preload + // to create the row. + await db.chat.upsert({ + where: { id: chatId }, + create: { id: chatId, messages: stored }, + update: { messages: stored }, + }); } return stored; diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx index 9e03279b21..d5ec3abc4c 100644 --- a/docs/ai-chat/sessions.mdx +++ b/docs/ai-chat/sessions.mdx @@ -1,17 +1,81 @@ --- title: "Sessions" sidebarTitle: "Sessions" -description: "The durable, task-bound, bi-directional I/O primitive that backs chat.agent — sessions.list / open / start / close plus the SessionHandle (in/out) API." +description: "A Session is a pair of durable streams — input carries your users' messages to the agent, output carries everything the agent produces back — plus orchestration of the runs that process them." --- import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; -A **Session** is a durable, task-bound, bi-directional I/O channel pair. It outlives any single run: a Session row is keyed on a stable `externalId` (e.g. `chatId`), holds the conversation's identity across run boundaries, and exposes two realtime streams — `.in` (clients → task) and `.out` (task → clients). +**A Session is a pair of durable streams.** The input stream (`.in`) carries incoming user messages to your task. The output stream (`.out`) carries everything the agent produces back to your clients: AI generation parts (text, reasoning, tool calls) and any custom data parts you write. + +Sessions also **orchestrate the runs that process those streams**. A Session is keyed on your stable id (`externalId` — for chat, the `chatId`) and owns its current run: when a run suspends, idles out, or hands off to a new version, the Session starts or swaps to a fresh run and the streams carry on. Clients keep sending and reading against the same id; they never know a run changed underneath. + +```mermaid +flowchart LR + C[Browser / backend clients] -- "user messages" --> IN([Session .in]) + IN --> R["current run
(runs come and go)"] + R -- "text, reasoning, tool calls,
data parts" --> OUT([Session .out]) + OUT --> C +``` `chat.agent` is built on Sessions. You can also use them directly for any pattern that needs durable bi-directional streaming across runs: long-lived agent inboxes, multi-step approval flows, server-to-server pipelines that survive worker restarts. +## A minimal example + +A task that echoes whatever lands on its input stream, and a backend that starts the session, sends a message, and reads the reply: + +```ts trigger/inbox.ts +import { task, sessions } from "@trigger.dev/sdk"; + +export const inboxAgent = task({ + id: "inbox-agent", + run: async (payload: { sessionId: string }) => { + const session = sessions.open(payload.sessionId); + + while (true) { + // Suspends the run (no compute billed) until a record arrives. + const next = await session.in.wait<{ text: string }>({ timeout: "1h" }); + if (!next.ok) return; + await session.out.append({ type: "reply", text: `echo: ${next.output.text}` }); + } + }, +}); +``` + +```ts Your backend +import { sessions } from "@trigger.dev/sdk"; + +// Atomically create the session AND trigger its first run. +await sessions.start({ + type: "inbox", + externalId: userId, + taskIdentifier: "inbox-agent", + triggerConfig: { basePayload: { sessionId: userId } }, +}); + +const session = sessions.open(userId); +await session.in.send({ text: "hello" }); + +const stream = await session.out.read({ signal: AbortSignal.timeout(30_000) }); +for await (const chunk of stream) { + console.log(chunk); // { type: "reply", text: "echo: hello" } +} +``` + +The run can suspend, crash, or be replaced between the `send` and the `read` — the streams are durable, so nothing is lost and the client code doesn't change. + +## Sessions and runs + +One Session spans many runs over its lifetime. The Session row tracks `currentRunId`; the runs do the work: + +- **First run**: created atomically by `sessions.start` (no gap where the session exists but nothing is listening). +- **Idle suspend**: a run blocked on `in.wait` suspends and frees compute. A new record on `.in` wakes it. +- **Continuation**: when a run ends (idle timeout, `chat.endRun`, a crash, a version upgrade), the next incoming record triggers a fresh run against the same Session. The new run picks up the streams where the old one left off. + +This is what makes a Session the durable identity for a conversation: runs are an execution detail, the Session (and its `externalId`) is what your clients address. See [How it works](/ai-chat/how-it-works) for how `chat.agent` drives this loop. + ## When to reach for Sessions directly `chat.agent` handles 90% of chat-shaped workloads — message accumulation, the turn loop, stop signals, lifecycle hooks. Use the raw `sessions` API when you need any of: