@@ -5128,6 +5128,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
51285128 );
51295129}
51305130
5131+ const warnedHydrateMessagesDeprecated = new Set<string>();
5132+ function warnHydrateMessagesDeprecatedOnce(agentId: string) {
5133+ if (warnedHydrateMessagesDeprecated.has(agentId)) return;
5134+ warnedHydrateMessagesDeprecated.add(agentId);
5135+ console.warn(
5136+ `[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
5137+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
5138+ "lets the application own the model's context, with crash recovery and durable " +
5139+ "compaction that `hydrateMessages` never had."
5140+ );
5141+ }
5142+
51315143let warnedMissingOnAction = false;
51325144function warnMissingOnActionOnce() {
51335145 if (warnedMissingOnAction) return;
@@ -5338,8 +5350,9 @@ export type RecoveryPendingToolCall = {
53385350 * `chat.endRun()` with no buffered user messages, fresh chat, OOM retry
53395351 * after a successful turn-complete with no in-flight tail).
53405352 *
5341- * Does NOT fire when `hydrateMessages` is registered (the customer owns
5342- * persistence; recovery decisions live in their own DB query).
5353+ * Fires regardless of who owns the model's context. With `hydrateMessages`
5354+ * or a storage `loadContext`, the recovered tail reaches that hook in
5355+ * `previousMessages` on the next turn.
53435356 */
53445357export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
53455358 /** Task run context — same as `task({ run })` second-argument `ctx`. */
@@ -5409,8 +5422,9 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
54095422 * context, mutate its tool parts to inject synthesized results,
54105423 * collapse history, etc.
54115424 *
5412- * Ignored when `hydrateMessages` is registered (the hydrate hook
5413- * runs per-turn and overwrites the chain).
5425+ * With `hydrateMessages` or a storage `loadContext`, this chain is what
5426+ * the hook receives as `previousMessages` on the next turn; the hook's
5427+ * return value is the chain the model sees.
54145428 */
54155429 chain?: TUIM[];
54165430 /**
@@ -5983,9 +5997,9 @@ export type ChatAgentOptions<
59835997 * continuation after `chat.endRun()` with no buffered user, a fresh
59845998 * chat, or an OOM retry on top of a complete snapshot.
59855999 *
5986- * Does NOT fire when `hydrateMessages` is registered — that hook owns
5987- * the per-turn chain and overlapping recovery decisions belong in the
5988- * customer's DB .
6000+ * Fires regardless of who owns the model's context; a `hydrateMessages`
6001+ * hook or a storage `loadContext` receives the recovered tail in
6002+ * `previousMessages` on the next turn .
59896003 *
59906004 * Defaults (returned when the hook is omitted or returns no field):
59916005 * - With two or more in-flight users, the partial and the user it
@@ -6740,6 +6754,18 @@ function chatAgent<
67406754 ...restOptions
67416755 } = options;
67426756
6757+ if (hydrateMessages) {
6758+ const storageAtDefinition = transcriptStorageOverride ?? defaultStorage;
6759+ if (typeof storageAtDefinition.loadContext === "function") {
6760+ throw new Error(
6761+ `chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
6762+ "`loadContext`. Both would own the model's context; keep one. `hydrateMessages` is " +
6763+ "deprecated, so prefer `loadContext` on the storage."
6764+ );
6765+ }
6766+ warnHydrateMessagesDeprecatedOnce(options.id);
6767+ }
6768+
67436769 const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
67446770 const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
67456771
@@ -6890,6 +6916,22 @@ function chatAgent<
68906916 // swallow errors internally; the agent stays available either way.
68916917 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
68926918 const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
6919+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
6920+ /**
6921+ * Who supplies the model's context each turn: the deprecated
6922+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
6923+ * the runtime's own transcript.
6924+ */
6925+ const loadContextHook = hydrateMessages
6926+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6927+ hydrateMessages(event)
6928+ : storageLoadContext
6929+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6930+ storageLoadContext<TUIMessage>(
6931+ { chatId: event.chatId, clientData: event.clientData },
6932+ event
6933+ )
6934+ : undefined;
68936935 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
68946936 let bootTranscriptState: unknown = null;
68956937 /**
@@ -7066,7 +7108,7 @@ function chatAgent<
70667108 let bootInCursor: number | undefined;
70677109 let bootInCursorResolved = false;
70687110
7069- if (!hydrateMessages && couldHavePriorState) {
7111+ if (couldHavePriorState) {
70707112 // Single parent span for the whole boot read phase — snapshot
70717113 // read, session.out replay, session.in replay. Per-phase timing
70727114 // + result counts are attributes on the span.
@@ -7076,18 +7118,26 @@ function chatAgent<
70767118 // snapshot read
70777119 const snapStart = Date.now();
70787120 try {
7079- const loaded = await transcriptStorage.load<TUIMessage>({
7080- chatId: payload.chatId,
7081- clientData: bootClientData,
7082- });
7083- transcriptShadow = createTranscriptShadow(loaded.messages);
7084- bootTranscriptState = loaded.state;
7085- persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7086- bootSnapshot = {
7087- messages: loaded.messages,
7088- lastOutEventId: loaded.cursors?.lastOutEventId,
7089- lastInEventId: loaded.cursors?.lastInEventId,
7090- };
7121+ const loaded = hydrateMessages
7122+ ? undefined
7123+ : await transcriptStorage.load<TUIMessage>({
7124+ chatId: payload.chatId,
7125+ clientData: bootClientData,
7126+ });
7127+ if (loaded) {
7128+ transcriptShadow = createTranscriptShadow(
7129+ loaded.messages,
7130+ new Set(loaded.nonFinalIds ?? [])
7131+ );
7132+ bootTranscriptState = loaded.state;
7133+ transcriptState = loaded.state ?? null;
7134+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7135+ bootSnapshot = {
7136+ messages: loaded.messages,
7137+ lastOutEventId: loaded.cursors?.lastOutEventId,
7138+ lastInEventId: loaded.cursors?.lastInEventId,
7139+ };
7140+ }
70917141 } catch (error) {
70927142 logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
70937143 error: error instanceof Error ? error.message : String(error),
@@ -7225,7 +7275,7 @@ function chatAgent<
72257275 });
72267276
72277277 // ── Recovery boot + chain reconstruction ────────────────────────
7228- if (!hydrateMessages) {
7278+ {
72297279 const settledMessages = mergeByIdReplaceWins<TUIMessage>(
72307280 (bootSnapshot?.messages as TUIMessage[]) ?? [],
72317281 replayedSettled
@@ -7394,6 +7444,7 @@ function chatAgent<
73947444 // and it's safe because the route handler isn't subject to the
73957445 // `/in/append` 512 KiB cap.
73967446 if (
7447+ !hydrateMessages &&
73977448 accumulatedUIMessages.length === 0 &&
73987449 payload.trigger === "handover-prepare" &&
73997450 Array.isArray(payload.headStartMessages) &&
@@ -8080,11 +8131,11 @@ function chatAgent<
80808131 : currentWirePayload.action;
80818132
80828133 // Hydrate messages from backend if configured
8083- if (hydrateMessages ) {
8134+ if (loadContextHook ) {
80848135 const hydrated = await tracer.startActiveSpan(
80858136 "hydrateMessages()",
80868137 async () => {
8087- return hydrateMessages ({
8138+ return loadContextHook ({
80888139 chatId: currentWirePayload.chatId,
80898140 turn,
80908141 trigger: "action",
@@ -8172,7 +8223,7 @@ function chatAgent<
81728223 // incoming messages instead (gated on the pending handover).
81738224 if (
81748225 turn === 0 &&
8175- hydrateMessages &&
8226+ loadContextHook &&
81768227 cleanedUIMessages.length === 0 &&
81778228 (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
81788229 Array.isArray(payload.headStartMessages) &&
@@ -8209,7 +8260,7 @@ function chatAgent<
82098260 )) as TUIMessage[];
82108261 }
82118262
8212- if (hydrateMessages ) {
8263+ if (loadContextHook ) {
82138264 // Snapshot the ids the accumulator knew BEFORE this
82148265 // turn ran — used below to decide whether an
82158266 // incoming wire message is genuinely new or just a
@@ -8232,7 +8283,7 @@ function chatAgent<
82328283 const hydrated = await tracer.startActiveSpan(
82338284 "hydrateMessages()",
82348285 async () => {
8235- return hydrateMessages ({
8286+ return loadContextHook ({
82368287 chatId: currentWirePayload.chatId,
82378288 turn,
82388289 trigger: currentWirePayload.trigger as
0 commit comments