@@ -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 /**
@@ -7055,7 +7097,7 @@ function chatAgent<
70557097 let bootInCursor: number | undefined;
70567098 let bootInCursorResolved = false;
70577099
7058- if (!hydrateMessages && couldHavePriorState) {
7100+ if (couldHavePriorState) {
70597101 // Single parent span for the whole boot read phase — snapshot
70607102 // read, session.out replay, session.in replay. Per-phase timing
70617103 // + result counts are attributes on the span.
@@ -7065,18 +7107,22 @@ function chatAgent<
70657107 // snapshot read
70667108 const snapStart = Date.now();
70677109 try {
7068- const loaded = await transcriptStorage.load<TUIMessage>({
7069- chatId: payload.chatId,
7070- clientData: bootClientData,
7071- });
7072- transcriptShadow = createTranscriptShadow(loaded.messages);
7073- bootTranscriptState = loaded.state;
7074- persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7075- bootSnapshot = {
7076- messages: loaded.messages,
7077- lastOutEventId: loaded.cursors?.lastOutEventId,
7078- lastInEventId: loaded.cursors?.lastInEventId,
7079- };
7110+ const loaded = hydrateMessages
7111+ ? undefined
7112+ : await transcriptStorage.load<TUIMessage>({
7113+ chatId: payload.chatId,
7114+ clientData: bootClientData,
7115+ });
7116+ if (loaded) {
7117+ transcriptShadow = createTranscriptShadow(loaded.messages);
7118+ bootTranscriptState = loaded.state;
7119+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7120+ bootSnapshot = {
7121+ messages: loaded.messages,
7122+ lastOutEventId: loaded.cursors?.lastOutEventId,
7123+ lastInEventId: loaded.cursors?.lastInEventId,
7124+ };
7125+ }
70807126 } catch (error) {
70817127 logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
70827128 error: error instanceof Error ? error.message : String(error),
@@ -7214,7 +7260,7 @@ function chatAgent<
72147260 });
72157261
72167262 // ── Recovery boot + chain reconstruction ────────────────────────
7217- if (!hydrateMessages) {
7263+ {
72187264 const settledMessages = mergeByIdReplaceWins<TUIMessage>(
72197265 (bootSnapshot?.messages as TUIMessage[]) ?? [],
72207266 replayedSettled
@@ -7383,6 +7429,7 @@ function chatAgent<
73837429 // and it's safe because the route handler isn't subject to the
73847430 // `/in/append` 512 KiB cap.
73857431 if (
7432+ !hydrateMessages &&
73867433 accumulatedUIMessages.length === 0 &&
73877434 payload.trigger === "handover-prepare" &&
73887435 Array.isArray(payload.headStartMessages) &&
@@ -8069,11 +8116,11 @@ function chatAgent<
80698116 : currentWirePayload.action;
80708117
80718118 // Hydrate messages from backend if configured
8072- if (hydrateMessages ) {
8119+ if (loadContextHook ) {
80738120 const hydrated = await tracer.startActiveSpan(
80748121 "hydrateMessages()",
80758122 async () => {
8076- return hydrateMessages ({
8123+ return loadContextHook ({
80778124 chatId: currentWirePayload.chatId,
80788125 turn,
80798126 trigger: "action",
@@ -8161,7 +8208,7 @@ function chatAgent<
81618208 // incoming messages instead (gated on the pending handover).
81628209 if (
81638210 turn === 0 &&
8164- hydrateMessages &&
8211+ loadContextHook &&
81658212 cleanedUIMessages.length === 0 &&
81668213 (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
81678214 Array.isArray(payload.headStartMessages) &&
@@ -8198,7 +8245,7 @@ function chatAgent<
81988245 )) as TUIMessage[];
81998246 }
82008247
8201- if (hydrateMessages ) {
8248+ if (loadContextHook ) {
82028249 // Snapshot the ids the accumulator knew BEFORE this
82038250 // turn ran — used below to decide whether an
82048251 // incoming wire message is genuinely new or just a
@@ -8221,7 +8268,7 @@ function chatAgent<
82218268 const hydrated = await tracer.startActiveSpan(
82228269 "hydrateMessages()",
82238270 async () => {
8224- return hydrateMessages ({
8271+ return loadContextHook ({
82258272 chatId: currentWirePayload.chatId,
82268273 turn,
82278274 trigger: currentWirePayload.trigger as
0 commit comments