@@ -5191,6 +5191,18 @@ function isUIMessageStreamable(value: unknown): value is UIMessageStreamable {
51915191 );
51925192}
51935193
5194+ const warnedHydrateMessagesDeprecated = new Set<string>();
5195+ function warnHydrateMessagesDeprecatedOnce(agentId: string) {
5196+ if (warnedHydrateMessagesDeprecated.has(agentId)) return;
5197+ warnedHydrateMessagesDeprecated.add(agentId);
5198+ console.warn(
5199+ `[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
5200+ "storage instead: `save` receives every change to the conversation and `loadContext` " +
5201+ "lets the application own the model's context, with crash recovery and durable " +
5202+ "compaction that `hydrateMessages` never had."
5203+ );
5204+ }
5205+
51945206let warnedMissingOnAction = false;
51955207function warnMissingOnActionOnce() {
51965208 if (warnedMissingOnAction) return;
@@ -5401,8 +5413,9 @@ export type RecoveryPendingToolCall = {
54015413 * `chat.endRun()` with no buffered user messages, fresh chat, OOM retry
54025414 * after a successful turn-complete with no in-flight tail).
54035415 *
5404- * Does NOT fire when `hydrateMessages` is registered (the customer owns
5405- * persistence; recovery decisions live in their own DB query).
5416+ * Fires regardless of who owns the model's context. With `hydrateMessages`
5417+ * or a storage `loadContext`, the recovered tail reaches that hook in
5418+ * `previousMessages` on the next turn.
54065419 */
54075420export type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
54085421 /** Task run context — same as `task({ run })` second-argument `ctx`. */
@@ -5472,8 +5485,9 @@ export type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
54725485 * context, mutate its tool parts to inject synthesized results,
54735486 * collapse history, etc.
54745487 *
5475- * Ignored when `hydrateMessages` is registered (the hydrate hook
5476- * runs per-turn and overwrites the chain).
5488+ * With `hydrateMessages` or a storage `loadContext`, this chain is what
5489+ * the hook receives as `previousMessages` on the next turn; the hook's
5490+ * return value is the chain the model sees.
54775491 */
54785492 chain?: TUIM[];
54795493 /**
@@ -6058,9 +6072,9 @@ export type ChatAgentOptions<
60586072 * continuation after `chat.endRun()` with no buffered user, a fresh
60596073 * chat, or an OOM retry on top of a complete snapshot.
60606074 *
6061- * Does NOT fire when `hydrateMessages` is registered — that hook owns
6062- * the per-turn chain and overlapping recovery decisions belong in the
6063- * customer's DB .
6075+ * Fires regardless of who owns the model's context; a `hydrateMessages`
6076+ * hook or a storage `loadContext` receives the recovered tail in
6077+ * `previousMessages` on the next turn .
60646078 *
60656079 * Defaults (returned when the hook is omitted or returns no field):
60666080 * - With two or more in-flight users, the partial and the user it
@@ -6832,6 +6846,18 @@ function chatAgent<
68326846 ...restOptions
68336847 } = options;
68346848
6849+ if (hydrateMessages) {
6850+ const storageAtDefinition = transcriptStorageOverride ?? defaultStorage;
6851+ if (typeof storageAtDefinition.loadContext === "function") {
6852+ throw new Error(
6853+ `chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
6854+ "`loadContext`. Both would own the model's context; keep one. `hydrateMessages` is " +
6855+ "deprecated, so prefer `loadContext` on the storage."
6856+ );
6857+ }
6858+ warnHydrateMessagesDeprecatedOnce(options.id);
6859+ }
6860+
68356861 const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
68366862 const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
68376863
@@ -6999,6 +7025,22 @@ function chatAgent<
69997025 // swallow errors internally; the agent stays available either way.
70007026 const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
70017027 const transcriptStorage = transcriptStorageOverride ?? defaultStorage;
7028+ const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
7029+ /**
7030+ * Who supplies the model's context each turn: the deprecated
7031+ * `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
7032+ * the runtime's own transcript.
7033+ */
7034+ const loadContextHook = hydrateMessages
7035+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
7036+ hydrateMessages(event)
7037+ : storageLoadContext
7038+ ? (event: HydrateMessagesEvent<inferSchemaOut<TClientDataSchema>, TUIMessage>) =>
7039+ storageLoadContext<TUIMessage>(
7040+ { chatId: event.chatId, clientData: event.clientData },
7041+ event
7042+ )
7043+ : undefined;
70027044 let transcriptShadow: TranscriptShadow = createTranscriptShadow([]);
70037045 let bootTranscriptState: unknown = null;
70047046 /**
@@ -7181,7 +7223,7 @@ function chatAgent<
71817223 let bootInCursor: number | undefined;
71827224 let bootInCursorResolved = false;
71837225
7184- if (!hydrateMessages && couldHavePriorState) {
7226+ if (couldHavePriorState) {
71857227 // Single parent span for the whole boot read phase — snapshot
71867228 // read, session.out replay, session.in replay. Per-phase timing
71877229 // + result counts are attributes on the span.
@@ -7191,18 +7233,26 @@ function chatAgent<
71917233 // snapshot read
71927234 const snapStart = Date.now();
71937235 try {
7194- const loaded = await transcriptStorage.load<TUIMessage>({
7195- chatId: payload.chatId,
7196- clientData: bootClientData,
7197- });
7198- transcriptShadow = createTranscriptShadow(loaded.messages);
7199- bootTranscriptState = loaded.state;
7200- persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7201- bootSnapshot = {
7202- messages: loaded.messages,
7203- lastOutEventId: loaded.cursors?.lastOutEventId,
7204- lastInEventId: loaded.cursors?.lastInEventId,
7205- };
7236+ const loaded = hydrateMessages
7237+ ? undefined
7238+ : await transcriptStorage.load<TUIMessage>({
7239+ chatId: payload.chatId,
7240+ clientData: bootClientData,
7241+ });
7242+ if (loaded) {
7243+ transcriptShadow = createTranscriptShadow(
7244+ loaded.messages,
7245+ new Set(loaded.nonFinalIds ?? [])
7246+ );
7247+ bootTranscriptState = loaded.state;
7248+ transcriptState = loaded.state ?? null;
7249+ persistedStateSet = loaded.state !== null && loaded.state !== undefined;
7250+ bootSnapshot = {
7251+ messages: loaded.messages,
7252+ lastOutEventId: loaded.cursors?.lastOutEventId,
7253+ lastInEventId: loaded.cursors?.lastInEventId,
7254+ };
7255+ }
72067256 } catch (error) {
72077257 logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
72087258 error: error instanceof Error ? error.message : String(error),
@@ -7346,7 +7396,7 @@ function chatAgent<
73467396 });
73477397
73487398 // ── Recovery boot + chain reconstruction ────────────────────────
7349- if (!hydrateMessages) {
7399+ {
73507400 const settledMessages = mergeByIdReplaceWins<TUIMessage>(
73517401 (bootSnapshot?.messages as TUIMessage[]) ?? [],
73527402 replayedSettled
@@ -7504,6 +7554,7 @@ function chatAgent<
75047554 // and it's safe because the route handler isn't subject to the
75057555 // `/in/append` 512 KiB cap.
75067556 if (
7557+ !loadContextHook &&
75077558 accumulatedUIMessages.length === 0 &&
75087559 payload.trigger === "handover-prepare" &&
75097560 Array.isArray(payload.headStartMessages) &&
@@ -8201,11 +8252,11 @@ function chatAgent<
82018252 : currentWirePayload.action;
82028253
82038254 // Hydrate messages from backend if configured
8204- if (hydrateMessages ) {
8255+ if (loadContextHook ) {
82058256 const hydrated = await tracer.startActiveSpan(
82068257 "hydrateMessages()",
82078258 async () => {
8208- return hydrateMessages ({
8259+ return loadContextHook ({
82098260 chatId: currentWirePayload.chatId,
82108261 turn,
82118262 trigger: "action",
@@ -8293,7 +8344,7 @@ function chatAgent<
82938344 // incoming messages instead (gated on the pending handover).
82948345 if (
82958346 turn === 0 &&
8296- hydrateMessages &&
8347+ loadContextHook &&
82978348 cleanedUIMessages.length === 0 &&
82988349 (locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
82998350 Array.isArray(payload.headStartMessages) &&
@@ -8330,7 +8381,7 @@ function chatAgent<
83308381 )) as TUIMessage[];
83318382 }
83328383
8333- if (hydrateMessages ) {
8384+ if (loadContextHook ) {
83348385 // Snapshot the ids the accumulator knew BEFORE this
83358386 // turn ran — used below to decide whether an
83368387 // incoming wire message is genuinely new or just a
@@ -8353,7 +8404,7 @@ function chatAgent<
83538404 const hydrated = await tracer.startActiveSpan(
83548405 "hydrateMessages()",
83558406 async () => {
8356- return hydrateMessages ({
8407+ return loadContextHook ({
83578408 chatId: currentWirePayload.chatId,
83588409 turn,
83598410 trigger: currentWirePayload.trigger as
0 commit comments