@@ -5142,6 +5142,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
51425142 );
51435143}
51445144
5145+ const warnedHydrateMessagesDeprecated = new Set<string>();
5146+ function warnHydrateMessagesDeprecatedOnce(agentId: string) {
5147+ if (warnedHydrateMessagesDeprecated.has(agentId)) return;
5148+ warnedHydrateMessagesDeprecated.add(agentId);
5149+ console.warn(
5150+ `[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
5151+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
5152+ "lets the application own the model's context, with crash recovery and durable " +
5153+ "compaction that `hydrateMessages` never had."
5154+ );
5155+ }
5156+
51455157let warnedMissingOnAction = false;
51465158function warnMissingOnActionOnce() {
51475159 if (warnedMissingOnAction) return;
@@ -5352,8 +5364,9 @@ export type RecoveryPendingToolCall = {
53525364 * `chat.endRun()` with no buffered user messages, fresh chat, OOM retry
53535365 * after a successful turn-complete with no in-flight tail).
53545366 *
5355- * Does NOT fire when `hydrateMessages` is registered (the customer owns
5356- * persistence; recovery decisions live in their own DB query).
5367+ * Fires regardless of who owns the model's context. With `hydrateMessages`
5368+ * or a storage `loadContext`, the recovered tail reaches that hook in
5369+ * `previousMessages` on the next turn.
53575370 */
53585371export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
53595372 /** Task run context — same as `task({ run })` second-argument `ctx`. */
@@ -5423,8 +5436,9 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
54235436 * context, mutate its tool parts to inject synthesized results,
54245437 * collapse history, etc.
54255438 *
5426- * Ignored when `hydrateMessages` is registered (the hydrate hook
5427- * runs per-turn and overwrites the chain).
5439+ * With `hydrateMessages` or a storage `loadContext`, this chain is what
5440+ * the hook receives as `previousMessages` on the next turn; the hook's
5441+ * return value is the chain the model sees.
54285442 */
54295443 chain?: TUIM[];
54305444 /**
@@ -5997,9 +6011,9 @@ export type ChatAgentOptions<
59976011 * continuation after `chat.endRun()` with no buffered user, a fresh
59986012 * chat, or an OOM retry on top of a complete snapshot.
59996013 *
6000- * Does NOT fire when `hydrateMessages` is registered — that hook owns
6001- * the per-turn chain and overlapping recovery decisions belong in the
6002- * customer's DB .
6014+ * Fires regardless of who owns the model's context; a `hydrateMessages`
6015+ * hook or a storage `loadContext` receives the recovered tail in
6016+ * `previousMessages` on the next turn .
60036017 *
60046018 * Defaults (returned when the hook is omitted or returns no field):
60056019 * - With two or more in-flight users, the partial and the user it
@@ -6754,6 +6768,18 @@ function chatAgent<
67546768 ...restOptions
67556769 } = options;
67566770
6771+ if (hydrateMessages) {
6772+ const storageAtDefinition = transcriptStorageOverride ?? defaultStorage;
6773+ if (typeof storageAtDefinition.loadContext === "function") {
6774+ throw new Error(
6775+ `chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
6776+ "`loadContext`. Both would own the model's context; keep one. `hydrateMessages` is " +
6777+ "deprecated, so prefer `loadContext` on the storage."
6778+ );
6779+ }
6780+ warnHydrateMessagesDeprecatedOnce(options.id);
6781+ }
6782+
67576783 const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
67586784 const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
67596785
@@ -6921,6 +6947,22 @@ function chatAgent<
69216947 // swallow errors internally; the agent stays available either way.
69226948 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
69236949 const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
6950+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
6951+ /**
6952+ * Who supplies the model's context each turn: the deprecated
6953+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
6954+ * the runtime's own transcript.
6955+ */
6956+ const loadContextHook = hydrateMessages
6957+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6958+ hydrateMessages(event)
6959+ : storageLoadContext
6960+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
6961+ storageLoadContext<TUIMessage>(
6962+ { chatId: event.chatId, clientData: event.clientData },
6963+ event
6964+ )
6965+ : undefined;
69246966 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
69256967 let bootTranscriptState: unknown = null;
69266968 /**
@@ -7103,7 +7145,7 @@ function chatAgent<
71037145 let bootInCursor: number | undefined;
71047146 let bootInCursorResolved = false;
71057147
7106- if (!hydrateMessages && couldHavePriorState) {
7148+ if (couldHavePriorState) {
71077149 // Single parent span for the whole boot read phase — snapshot
71087150 // read, session.out replay, session.in replay. Per-phase timing
71097151 // + result counts are attributes on the span.
@@ -7113,18 +7155,26 @@ function chatAgent<
71137155 // snapshot read
71147156 const snapStart = Date.now();
71157157 try {
7116- const loaded = await transcriptStorage.load<TUIMessage>({
7117- chatId: payload.chatId,
7118- clientData: bootClientData,
7119- });
7120- transcriptShadow = createTranscriptShadow(loaded.messages);
7121- bootTranscriptState = loaded.state;
7122- persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7123- bootSnapshot = {
7124- messages: loaded.messages,
7125- lastOutEventId: loaded.cursors?.lastOutEventId,
7126- lastInEventId: loaded.cursors?.lastInEventId,
7127- };
7158+ const loaded = hydrateMessages
7159+ ? undefined
7160+ : await transcriptStorage.load<TUIMessage>({
7161+ chatId: payload.chatId,
7162+ clientData: bootClientData,
7163+ });
7164+ if (loaded) {
7165+ transcriptShadow = createTranscriptShadow(
7166+ loaded.messages,
7167+ new Set(loaded.nonFinalIds ?? [])
7168+ );
7169+ bootTranscriptState = loaded.state;
7170+ transcriptState = loaded.state ?? null;
7171+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7172+ bootSnapshot = {
7173+ messages: loaded.messages,
7174+ lastOutEventId: loaded.cursors?.lastOutEventId,
7175+ lastInEventId: loaded.cursors?.lastInEventId,
7176+ };
7177+ }
71287178 } catch (error) {
71297179 logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
71307180 error: error instanceof Error ? error.message : String(error),
@@ -7262,7 +7312,7 @@ function chatAgent<
72627312 });
72637313
72647314 // ── Recovery boot + chain reconstruction ────────────────────────
7265- if (!hydrateMessages) {
7315+ {
72667316 const settledMessages = mergeByIdReplaceWins<TUIMessage>(
72677317 (bootSnapshot?.messages as TUIMessage[]) ?? [],
72687318 replayedSettled
@@ -7431,6 +7481,7 @@ function chatAgent<
74317481 // and it's safe because the route handler isn't subject to the
74327482 // `/in/append` 512 KiB cap.
74337483 if (
7484+ !loadContextHook &&
74347485 accumulatedUIMessages.length === 0 &&
74357486 payload.trigger === "handover-prepare" &&
74367487 Array.isArray(payload.headStartMessages) &&
@@ -8125,11 +8176,11 @@ function chatAgent<
81258176 : currentWirePayload.action;
81268177
81278178 // Hydrate messages from backend if configured
8128- if (hydrateMessages ) {
8179+ if (loadContextHook ) {
81298180 const hydrated = await tracer.startActiveSpan(
81308181 "hydrateMessages()",
81318182 async () => {
8132- return hydrateMessages ({
8183+ return loadContextHook ({
81338184 chatId: currentWirePayload.chatId,
81348185 turn,
81358186 trigger: "action",
@@ -8217,7 +8268,7 @@ function chatAgent<
82178268 // incoming messages instead (gated on the pending handover).
82188269 if (
82198270 turn === 0 &&
8220- hydrateMessages &&
8271+ loadContextHook &&
82218272 cleanedUIMessages.length === 0 &&
82228273 (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
82238274 Array.isArray(payload.headStartMessages) &&
@@ -8254,7 +8305,7 @@ function chatAgent<
82548305 )) as TUIMessage[];
82558306 }
82568307
8257- if (hydrateMessages ) {
8308+ if (loadContextHook ) {
82588309 // Snapshot the ids the accumulator knew BEFORE this
82598310 // turn ran — used below to decide whether an
82608311 // incoming wire message is genuinely new or just a
@@ -8277,7 +8328,7 @@ function chatAgent<
82778328 const hydrated = await tracer.startActiveSpan(
82788329 "hydrateMessages()",
82798330 async () => {
8280- return hydrateMessages ({
8331+ return loadContextHook ({
82818332 chatId: currentWirePayload.chatId,
82828333 turn,
82838334 trigger: currentWirePayload.trigger as
0 commit comments