@@ -2128,6 +2128,103 @@ function extractNewToolResultsFromHistory(
21282128 return out ;
21292129}
21302130
2131+ /**
2132+ * Per-turn merge of an incoming wire `UIMessage` onto the matching entry
2133+ * a `hydrateMessages` hook (or the default accumulator) provides. Used
2134+ * to fold tool-state advances from the client into the agent's
2135+ * authoritative chain without trusting the wire copy for fields the
2136+ * LLM consumes.
2137+ *
2138+ * `hydrated` is treated as the source of truth for everything outside
2139+ * tool-state advancement: text, reasoning blobs, provider metadata,
2140+ * and tool `input` all stay as hydrated had them. We only overlay
2141+ * tool parts whose incoming state is wire-advanced — `output-available`
2142+ * / `output-error` (HITL `addToolOutput`) or `approval-responded` /
2143+ * `output-denied` (approval flow) — and only the corresponding
2144+ * resolution fields (`output` / `errorText` / `approval`). Hydrated
2145+ * `input` and everything else stay put.
2146+ *
2147+ * Without this, a slim wire copy (which `TriggerChatTransport` /
2148+ * `AgentChat.sendRaw` ship by default on HITL continuations) would
2149+ * clobber the hydrated assistant — the next LLM call would receive a
2150+ * tool call with no `input` and 4xx.
2151+ *
2152+ * @internal
2153+ */
2154+ function mergeIncomingIntoHydrated < TMsg extends UIMessage > (
2155+ hydrated : TMsg ,
2156+ incoming : UIMessage
2157+ ) : TMsg {
2158+ const incomingAdvancedByCallId = new Map < string , any > ( ) ;
2159+ for ( const part of ( incoming . parts ?? [ ] ) as any [ ] ) {
2160+ if ( ! isToolUIPart ( part ) ) continue ;
2161+ const toolCallId = part . toolCallId ;
2162+ if ( typeof toolCallId !== "string" || toolCallId . length === 0 ) continue ;
2163+ if ( ! isWireAdvanceableToolState ( part . state ) ) continue ;
2164+ incomingAdvancedByCallId . set ( toolCallId , part ) ;
2165+ }
2166+
2167+ if ( incomingAdvancedByCallId . size === 0 ) return hydrated ;
2168+
2169+ let mutated = false ;
2170+ const hydratedParts = ( hydrated . parts ?? [ ] ) as any [ ] ;
2171+ const mergedParts = hydratedParts . map ( ( part ) => {
2172+ if ( ! isToolUIPart ( part ) ) return part ;
2173+ const toolCallId = part . toolCallId ;
2174+ if ( typeof toolCallId !== "string" || toolCallId . length === 0 ) return part ;
2175+ const incomingPart = incomingAdvancedByCallId . get ( toolCallId ) ;
2176+ if ( ! incomingPart ) return part ;
2177+ // Hydrated already carries a resolved state for this call — treat
2178+ // it as authoritative and ignore the wire copy. Repeat sends of the
2179+ // same answer (replay, retry) are no-ops.
2180+ if ( isResolvedToolState ( part . state ) ) return part ;
2181+ mutated = true ;
2182+ if ( incomingPart . state === "output-available" ) {
2183+ return {
2184+ ...part ,
2185+ state : incomingPart . state ,
2186+ output : incomingPart . output ,
2187+ ...( incomingPart . approval !== undefined ? { approval : incomingPart . approval } : { } ) ,
2188+ } ;
2189+ }
2190+ if ( incomingPart . state === "output-error" ) {
2191+ return {
2192+ ...part ,
2193+ state : incomingPart . state ,
2194+ errorText : incomingPart . errorText ,
2195+ ...( incomingPart . approval !== undefined ? { approval : incomingPart . approval } : { } ) ,
2196+ } ;
2197+ }
2198+ // approval-responded / output-denied — overlay state + approval.
2199+ return {
2200+ ...part ,
2201+ state : incomingPart . state ,
2202+ ...( incomingPart . approval !== undefined ? { approval : incomingPart . approval } : { } ) ,
2203+ } ;
2204+ } ) ;
2205+
2206+ if ( ! mutated ) return hydrated ;
2207+ return { ...hydrated , parts : mergedParts } ;
2208+ }
2209+
2210+ /**
2211+ * Mirror of `slimSubmitMessageForWire`'s predicate. Kept here so the
2212+ * agent runtime doesn't have to import from `ai-shared.ts` for a
2213+ * one-liner. See that file for the full state-machine docs.
2214+ *
2215+ * @internal
2216+ */
2217+ function isWireAdvanceableToolState (
2218+ state : unknown
2219+ ) : state is "output-available" | "output-error" | "approval-responded" | "output-denied" {
2220+ return (
2221+ state === "output-available" ||
2222+ state === "output-error" ||
2223+ state === "approval-responded" ||
2224+ state === "output-denied"
2225+ ) ;
2226+ }
2227+
21312228/**
21322229 * Imperative API for reading and modifying the accumulated message history.
21332230 *
@@ -3876,7 +3973,14 @@ export type HydrateMessagesEvent<TClientData = unknown, TUIM extends UIMessage =
38763973 * Event passed to the `onValidateMessages` callback.
38773974 */
38783975export type ValidateMessagesEvent < TUIM extends UIMessage = UIMessage > = {
3879- /** The incoming UI messages for this turn (after cleanup of aborted tool parts). */
3976+ /**
3977+ * The incoming UI messages for this turn (after cleanup of aborted tool parts).
3978+ *
3979+ * For HITL continuations the assistant entry is slim — `state` + `output` /
3980+ * `errorText` / `approval` only, no `input` or other parts. Don't pass the
3981+ * full `messages` array to `validateUIMessages` from `ai`; filter to user
3982+ * messages (or your own subset) first.
3983+ */
38803984 messages : TUIM [ ] ;
38813985 /** The unique identifier for the chat session. */
38823986 chatId : string ;
@@ -4372,8 +4476,13 @@ export type ChatAgentOptions<
43724476 *
43734477 * Return the validated messages array. Throw to abort the turn with an error.
43744478 *
4375- * This is the right place to call the AI SDK's `validateUIMessages` to catch
4376- * malformed messages from storage or untrusted input before they reach the model.
4479+ * This is the right place to call the AI SDK's `validateUIMessages` on fresh
4480+ * user input. For HITL continuations (`addToolOutput` /
4481+ * `addToolApproveResponse`), the wire carries a slim assistant message — only
4482+ * the resolved tool parts, with `state` + `output` / `errorText` / `approval`
4483+ * and no `input`. `validateUIMessages` against the AI SDK schema rejects
4484+ * that shape, so filter to user messages (or skip validation entirely) on
4485+ * those turns.
43774486 *
43784487 * @example
43794488 * ```ts
@@ -4382,7 +4491,11 @@ export type ChatAgentOptions<
43824491 * chat.agent({
43834492 * id: "my-chat",
43844493 * onValidateMessages: async ({ messages }) => {
4385- * return validateUIMessages({ messages, tools: chatTools });
4494+ * const userMessages = messages.filter((m) => m.role === "user");
4495+ * if (userMessages.length > 0) {
4496+ * await validateUIMessages({ messages: userMessages, tools: chatTools });
4497+ * }
4498+ * return messages;
43864499 * },
43874500 * run: async ({ messages }) => {
43884501 * return streamText({ model, messages, tools: chatTools });
@@ -6071,30 +6184,47 @@ function chatAgent<
60716184 }
60726185 ) ;
60736186
6074- // Auto-merge tool approval updates: if any incoming wire message
6075- // has an ID that matches a hydrated message, replace it. This makes
6076- // tool approvals work transparently with backend hydration.
6187+ // Per-turn merge of incoming wire messages onto the hydrated
6188+ // chain. Hydrated stays authoritative for text, reasoning
6189+ // blobs, provider metadata, and tool `input`; we only
6190+ // overlay tool-part state/output/errorText for tool calls
6191+ // the wire copy has just resolved. Apps that slim the wire
6192+ // copy to fit the .in/append cap (or drop fields they
6193+ // re-source from their own DB) get the hydrated copy
6194+ // through unchanged.
60776195 const merged = [ ...hydrated ] as TUIMessage [ ] ;
60786196 for ( const incoming of cleanedUIMessages ) {
60796197 if ( ! incoming . id ) continue ;
60806198 const idx = merged . findIndex ( ( m ) => m . id === incoming . id ) ;
60816199 if ( idx !== - 1 ) {
6082- merged [ idx ] = incoming as TUIMessage ;
6200+ merged [ idx ] = mergeIncomingIntoHydrated (
6201+ merged [ idx ] ! ,
6202+ incoming
6203+ ) as TUIMessage ;
60836204 }
60846205 }
60856206
60866207 accumulatedUIMessages = merged ;
60876208 accumulatedMessages = await toModelMessages ( merged ) ;
60886209 locals . set ( chatCurrentUIMessagesKey , accumulatedUIMessages ) ;
60896210
6090- // Track new messages for onTurnComplete.newUIMessages
6211+ // Track new messages for onTurnComplete.newUIMessages.
6212+ // Surface the post-merge entry when the wire copy
6213+ // matched a hydrated message — the wire copy may have
6214+ // been slimmed (HITL tool-output continuation), and
6215+ // customers expect `newUIMessages` to carry full
6216+ // content (text, reasoning, tool `input`).
60916217 if (
60926218 currentWirePayload . trigger === "submit-message" &&
60936219 cleanedUIMessages . length > 0
60946220 ) {
60956221 const lastUI = cleanedUIMessages [ cleanedUIMessages . length - 1 ] ! ;
6096- turnNewUIMessages . push ( lastUI ) ;
6097- const lastModel = ( await toModelMessages ( [ lastUI ] ) ) [ 0 ] ;
6222+ const mergedEntry = lastUI . id
6223+ ? merged . find ( ( m ) => m . id === lastUI . id )
6224+ : undefined ;
6225+ const surfaceUI = ( mergedEntry ?? lastUI ) as TUIMessage ;
6226+ turnNewUIMessages . push ( surfaceUI ) ;
6227+ const lastModel = ( await toModelMessages ( [ surfaceUI ] ) ) [ 0 ] ;
60986228 if ( lastModel ) turnNewModelMessages . push ( lastModel ) ;
60996229 }
61006230 } else {
@@ -6121,15 +6251,17 @@ function chatAgent<
61216251 } else if ( cleanedUIMessages . length > 0 ) {
61226252 // Submit-message (and the special-cased
61236253 // handover-prepare → submit-message rewrite earlier in
6124- // this scope): append -or-replace-by-id for the single
6125- // delta message.
6254+ // this scope): merge -or-append for the single delta
6255+ // message.
61266256 //
61276257 // Tool approval responses arrive as a single assistant
61286258 // message whose id collides with the existing assistant
6129- // in the accumulator — we replace by id. The fallback
6130- // for HITL `addToolOutput` continuations where AI SDK
6131- // regenerates the id (TRI-9137) still applies via
6132- // `rewriteIncomingIdViaToolCallMap`.
6259+ // in the accumulator — we merge the resolved tool-part
6260+ // resolutions onto the existing entry, keeping text,
6261+ // reasoning, and tool `input` from the prior snapshot.
6262+ // The fallback for HITL `addToolOutput` continuations
6263+ // where AI SDK regenerates the id (TRI-9137) still
6264+ // applies via `rewriteIncomingIdViaToolCallMap`.
61336265 let replaced = false ;
61346266 for ( const raw of cleanedUIMessages ) {
61356267 let incoming = raw ;
@@ -6146,7 +6278,10 @@ function chatAgent<
61466278 }
61476279 }
61486280 if ( idx !== - 1 ) {
6149- accumulatedUIMessages [ idx ] = incoming as TUIMessage ;
6281+ accumulatedUIMessages [ idx ] = mergeIncomingIntoHydrated (
6282+ accumulatedUIMessages [ idx ] ! ,
6283+ incoming
6284+ ) as TUIMessage ;
61506285 replaced = true ;
61516286 } else {
61526287 accumulatedUIMessages . push ( incoming as TUIMessage ) ;
0 commit comments