From 44b58f4f7c779a54c0f82b0afffdd6c8a5226bc2 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:06:04 +0800 Subject: [PATCH 01/13] fix(ai): preserve AG-UI tool result metadata --- packages/ai/src/activities/chat/stream/processor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 917406236f..61b7265f65 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -2647,6 +2647,7 @@ export class StreamProcessor { resultState, resultState === 'error' ? toolResultErrorText(output) : undefined, toolResultOutcome, + chunk.metadata, ) this.emitMessagesChange() } From da0acf519ee6d252c313014b3d3315e0789e47cb Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:06:06 +0800 Subject: [PATCH 02/13] fix(ai): preserve AG-UI tool result metadata --- .../ai/src/activities/chat/stream/message-updaters.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ai/src/activities/chat/stream/message-updaters.ts b/packages/ai/src/activities/chat/stream/message-updaters.ts index b7e936beab..e5ddead6ac 100644 --- a/packages/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/ai/src/activities/chat/stream/message-updaters.ts @@ -6,6 +6,7 @@ */ import { isRedactedThinkingId } from '../../../utilities/reasoning-encrypted-value' +import { mergeMetadata } from '../../../utilities/merge-metadata' import { parsePartialJSON } from './json-parser' import type { ContentPart, @@ -120,6 +121,7 @@ export function updateToolResultPart( state: ToolResultState, error?: string, outcome?: ToolResultOutcome, + metadata?: Record, ): Array { return messages.map((msg) => { if (msg.id !== messageId) { @@ -132,6 +134,14 @@ export function updateToolResultPart( p.type === 'tool-result' && p.toolCallId === toolCallId, ) + const existingResultPart = + resultPartIndex >= 0 + ? (parts[resultPartIndex] as ToolResultPart) + : undefined + // Preserve existing metadata when a later result update does not include + // metadata, and merge partial metadata (including metadata.tanstack). + const mergedMetadata = mergeMetadata(existingResultPart?.metadata, metadata) + const toolResultPart: ToolResultPart = { type: 'tool-result', toolCallId, @@ -139,6 +149,7 @@ export function updateToolResultPart( state, ...(outcome !== undefined && { outcome }), ...(error && { error }), + ...(mergedMetadata !== undefined && { metadata: mergedMetadata }), } if (resultPartIndex >= 0) { From b5c1c78619468fbba2acf90be046a0cba118d12d Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:06:09 +0800 Subject: [PATCH 03/13] test(ai): cover tool result metadata preservation --- packages/ai/tests/message-updaters.test.ts | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/ai/tests/message-updaters.test.ts b/packages/ai/tests/message-updaters.test.ts index fc74c4e347..c75b1f20ec 100644 --- a/packages/ai/tests/message-updaters.test.ts +++ b/packages/ai/tests/message-updaters.test.ts @@ -386,6 +386,28 @@ describe('message-updaters', () => { }) }) + it('should include metadata on a new tool result', () => { + const messages = [createMessage('msg-1')] + const metadata = { displayType: 'account-assets', accountId: 'A001' } + + const result = updateToolResultPart( + messages, + 'msg-1', + 'call-1', + '{"totalAssets":1000000}', + 'complete', + undefined, + undefined, + metadata, + ) + + expect(result[0]?.parts[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'call-1', + metadata, + }) + }) + it('should update an existing tool result by toolCallId', () => { const messages = [ createMessage('msg-1', 'assistant', [ @@ -414,6 +436,70 @@ describe('message-updaters', () => { }) }) + it('should preserve existing metadata when an update omits metadata', () => { + const metadata = { displayType: 'account-assets' } + const messages = [ + createMessage('msg-1', 'assistant', [ + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"temperature":', + state: 'streaming', + metadata, + }, + ]), + ] + + const result = updateToolResultPart( + messages, + 'msg-1', + 'call-1', + '{"temperature":20}', + 'complete', + ) + + expect(result[0]?.parts[0]).toMatchObject({ metadata }) + }) + + it('should merge incoming metadata with existing metadata', () => { + const messages = [ + createMessage('msg-1', 'assistant', [ + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"temperature":', + state: 'streaming', + metadata: { + displayType: 'account-assets', + tanstack: { existing: true, state: 'streaming' }, + }, + }, + ]), + ] + + const result = updateToolResultPart( + messages, + 'msg-1', + 'call-1', + '{"temperature":20}', + 'complete', + undefined, + undefined, + { + accountId: 'A001', + tanstack: { state: 'complete' }, + }, + ) + + expect(result[0]?.parts[0]).toMatchObject({ + metadata: { + displayType: 'account-assets', + accountId: 'A001', + tanstack: { existing: true, state: 'complete' }, + }, + }) + }) + it('should include error when provided', () => { const messages = [createMessage('msg-1')] const result = updateToolResultPart( From 65844afbe282398d0074339df1e0d387f81e1939 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:06:13 +0800 Subject: [PATCH 04/13] test(ai): cover live AG-UI tool result metadata --- packages/ai/tests/stream-processor.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 40e04e1541..a9f6420ed0 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -6365,6 +6365,10 @@ describe('StreamProcessor', () => { it('should create tool-result part and update tool-call output', () => { const events = spyEvents() const processor = new StreamProcessor({ events }) + const metadata = { + displayType: 'account-assets', + accountId: 'A001', + } processor.processChunk(ev.runStarted()) processor.processChunk(ev.textStart()) @@ -6380,6 +6384,7 @@ describe('StreamProcessor', () => { toolCallId: 'tc-1', content: '{"temp": 72}', role: 'tool', + metadata, }), ) processor.processChunk(ev.runFinished('tool_calls')) @@ -6398,6 +6403,7 @@ describe('StreamProcessor', () => { expect(toolResultPart.toolCallId).toBe('tc-1') expect(toolResultPart.content).toBe('{"temp": 72}') expect(toolResultPart.state).toBe('complete') + expect(toolResultPart.metadata).toEqual(metadata) }) it('should mark output-error tool results as errored message parts', () => { From 2b45ba84f082d1e8376b800dbb94968f691a9266 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:06:47 +0800 Subject: [PATCH 05/13] test(ai): cover tool result metadata snapshot hydration --- packages/ai/tests/stream-processor.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index a9f6420ed0..7e225a3b89 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -176,6 +176,25 @@ function spyEvents(): MockedEvents { // ============================================================================ describe('StreamProcessor', () => { + it('preserves metadata on snapshot tool messages', () => { + const metadata = { + displayType: 'account-assets', + accountId: 'A001', + } + const message = aguiSnapshotMessageToUIMessage({ + id: 'tool-1', + role: 'tool', + toolCallId: 'call-1', + content: '{"totalAssets":1000000}', + metadata, + } as unknown as AGUIMessage) + + const toolResultPart = message.parts.find( + (part): part is ToolResultPart => part.type === 'tool-result', + ) + expect(toolResultPart?.metadata).toEqual(metadata) + }) + it('normalizes nested dates on snapshot existing tool-result parts', () => { const message = aguiSnapshotMessageToUIMessage({ id: 'a1', From 870543164f6a901209419c6df1b1c453023d653b Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:07:28 +0800 Subject: [PATCH 06/13] chore(ai): add changeset for tool result metadata fix --- .changeset/wacky-gifts-show.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wacky-gifts-show.md diff --git a/.changeset/wacky-gifts-show.md b/.changeset/wacky-gifts-show.md new file mode 100644 index 0000000000..ef5f58d8e3 --- /dev/null +++ b/.changeset/wacky-gifts-show.md @@ -0,0 +1,5 @@ +--- +"@tanstack/ai": patch +--- + +Preserve metadata from live AG-UI TOOL_CALL_RESULT events on ToolResultPart. \ No newline at end of file From 3a1b080ba5f70570b98d5074a51642751210d3a6 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 15:07:50 +0800 Subject: [PATCH 07/13] chore(ai): finalize changeset formatting --- .changeset/wacky-gifts-show.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wacky-gifts-show.md b/.changeset/wacky-gifts-show.md index ef5f58d8e3..17a75bb6f2 100644 --- a/.changeset/wacky-gifts-show.md +++ b/.changeset/wacky-gifts-show.md @@ -2,4 +2,4 @@ "@tanstack/ai": patch --- -Preserve metadata from live AG-UI TOOL_CALL_RESULT events on ToolResultPart. \ No newline at end of file +Preserve metadata from live AG-UI TOOL_CALL_RESULT events on ToolResultPart. From eff81403a93407e9e7fee5cdf760c8481e3d62bd Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 17:37:13 +0800 Subject: [PATCH 08/13] fix(ai): clear stale tool result outcome metadata --- .../chat/stream/message-updaters.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/activities/chat/stream/message-updaters.ts b/packages/ai/src/activities/chat/stream/message-updaters.ts index e5ddead6ac..72bd3fbb18 100644 --- a/packages/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/ai/src/activities/chat/stream/message-updaters.ts @@ -140,7 +140,43 @@ export function updateToolResultPart( : undefined // Preserve existing metadata when a later result update does not include // metadata, and merge partial metadata (including metadata.tanstack). - const mergedMetadata = mergeMetadata(existingResultPart?.metadata, metadata) + let mergedMetadata = mergeMetadata(existingResultPart?.metadata, metadata) + const incomingTanstack = metadata?.tanstack + const hasIncomingToolResultOutcome = + incomingTanstack != null && + typeof incomingTanstack === 'object' && + !Array.isArray(incomingTanstack) && + 'toolResultOutcome' in incomingTanstack && + incomingTanstack.toolResultOutcome !== undefined + + // A successful retry supersedes a previous denial/cancellation. Do not + // carry the old control outcome into the new result, or snapshot hydration + // will incorrectly restore the successful result as an error. + if ( + state === 'complete' && + outcome === undefined && + !hasIncomingToolResultOutcome && + mergedMetadata + ) { + const tanstack = mergedMetadata.tanstack + if ( + tanstack != null && + typeof tanstack === 'object' && + !Array.isArray(tanstack) && + 'toolResultOutcome' in tanstack + ) { + const { toolResultOutcome: _staleOutcome, ...restTanstack } = + tanstack as Record + const nextMetadata = { ...mergedMetadata } + if (Object.keys(restTanstack).length > 0) { + nextMetadata.tanstack = restTanstack + } else { + delete nextMetadata.tanstack + } + mergedMetadata = + Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined + } + } const toolResultPart: ToolResultPart = { type: 'tool-result', From 330a2658a1ff448c9c750b91b56e3d9f4275513e Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 17:37:16 +0800 Subject: [PATCH 09/13] fix(ai): preserve tool result metadata across snapshots --- .../src/activities/chat/stream/processor.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 61b7265f65..f91c70986e 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -2095,11 +2095,16 @@ export class StreamProcessor { // restore metadata the snapshot cannot re-emit. Duplicate ids resolve // last-write-wins: the same tool call can appear in multiple messages // across reconnects, and the most recent part carries the freshest state. + // Snapshot rows may omit metadata from previously streamed tool results. + // Index both part types by id so we can restore only the missing metadata. const prevToolCalls = new Map() + const prevToolResults = new Map() for (const msg of prevMessages) { for (const part of msg.parts) { if (part.type === 'tool-call') { prevToolCalls.set(part.id, part) + } else if (part.type === 'tool-result') { + prevToolResults.set(part.toolCallId, part) } } } @@ -2116,20 +2121,31 @@ export class StreamProcessor { const reconciled: Array = [] for (const msg of snapshot) { - const toolResultParts = msg.parts.filter( + let restoredToolResultMetadata = false + const snapshotParts = msg.parts.map((part) => { + if (part.type !== 'tool-result' || part.metadata != null) return part + const previous = prevToolResults.get(part.toolCallId) + if (previous?.metadata == null) return part + restoredToolResultMetadata = true + return { ...part, metadata: previous.metadata } + }) + const snapshotMessage = restoredToolResultMetadata + ? { ...msg, parts: snapshotParts } + : msg + const toolResultParts = snapshotParts.filter( (part): part is ToolResultPart => part.type === 'tool-result', ) const toolResultPart = msg.role === 'assistant' && toolResultParts.length === 1 && - msg.parts.every( + snapshotParts.every( (part) => part.type === 'tool-result' || part.type === 'ui-resource', ) ? toolResultParts[0] : undefined if (!toolResultPart) { - reconciled.push(msg) + reconciled.push(snapshotMessage) continue } @@ -2157,7 +2173,7 @@ export class StreamProcessor { `[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot, and there is no assistant message to anchor into; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`, ) } - reconciled.push(msg) + reconciled.push(snapshotMessage) continue } @@ -2183,7 +2199,7 @@ export class StreamProcessor { ) } } - parts.push(...msg.parts) + parts.push(...snapshotParts) // Replace rather than push into `target.parts`: a snapshot message that // arrived already carrying `parts` (TanStack server echoing UIMessages) // shares its array with the incoming chunk, and mutating it in place From d01394ae9398862f0c30a55f039123d60be89835 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 17:37:19 +0800 Subject: [PATCH 10/13] test(ai): cover tool result metadata update semantics --- packages/ai/tests/message-updaters.test.ts | 82 +++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/ai/tests/message-updaters.test.ts b/packages/ai/tests/message-updaters.test.ts index c75b1f20ec..50bbc8700b 100644 --- a/packages/ai/tests/message-updaters.test.ts +++ b/packages/ai/tests/message-updaters.test.ts @@ -458,7 +458,87 @@ describe('message-updaters', () => { 'complete', ) - expect(result[0]?.parts[0]).toMatchObject({ metadata }) + expect(result[0]?.parts[0]).toMatchObject({ + content: '{"temperature":20}', + state: 'complete', + metadata, + }) + }) + + it('should clear stale tool result outcome metadata on a successful update', () => { + const messages = [ + createMessage('msg-1', 'assistant', [ + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"error":"User declined"}', + state: 'error', + metadata: { + displayType: 'account-assets', + accountId: 'A001', + tanstack: { + toolResultOutcome: 'denied', + retained: true, + }, + }, + }, + ]), + ] + + const result = updateToolResultPart( + messages, + 'msg-1', + 'call-1', + '{"ok":true}', + 'complete', + ) + + expect(result[0]?.parts[0]).toEqual({ + type: 'tool-result', + toolCallId: 'call-1', + content: '{"ok":true}', + state: 'complete', + metadata: { + displayType: 'account-assets', + accountId: 'A001', + tanstack: { retained: true }, + }, + }) + }) + + it('should preserve outcome metadata explicitly supplied by an update', () => { + const incomingMetadata = { + displayType: 'account-assets', + tanstack: { toolResultOutcome: 'cancelled' }, + } + const messages = [ + createMessage('msg-1', 'assistant', [ + { + type: 'tool-result', + toolCallId: 'call-1', + content: '{"error":"User declined"}', + state: 'error', + metadata: { tanstack: { toolResultOutcome: 'denied' } }, + }, + ]), + ] + + const result = updateToolResultPart( + messages, + 'msg-1', + 'call-1', + '{"ok":true}', + 'complete', + undefined, + undefined, + incomingMetadata, + ) + + expect(result[0]?.parts[0]).toMatchObject({ + content: '{"ok":true}', + state: 'complete', + metadata: incomingMetadata, + }) }) it('should merge incoming metadata with existing metadata', () => { From c945c96280ea6a324e6b4a5632b903a411ec9201 Mon Sep 17 00:00:00 2001 From: Mike Gleichner Date: Fri, 6 Nov 2026 17:37:22 +0800 Subject: [PATCH 11/13] test(ai): cover tool result metadata snapshot recovery --- packages/ai/tests/stream-processor.test.ts | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 7e225a3b89..feaa8c9732 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -3893,6 +3893,81 @@ describe('StreamProcessor', () => { ]) }) + it('preserves streamed tool-result metadata when a snapshot omits it', () => { + const processor = new StreamProcessor() + const streamedMetadata = { + displayType: 'account-assets', + accountId: 'A001', + } + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart('a1')) + processor.processChunk(ev.textContent('Checking.', 'a1')) + processor.processChunk(ev.toolStart('tc-1', 'getWeather')) + processor.processChunk(ev.toolEnd('tc-1', 'getWeather')) + processor.processChunk( + chunk(EventType.TOOL_CALL_RESULT, { + messageId: 't1', + toolCallId: 'tc-1', + content: '{"temp":72}', + role: 'tool', + metadata: streamedMetadata, + }), + ) + processor.processChunk(ev.runFinished('tool_calls')) + + const snapshotMessages = (metadata?: Record) => [ + { + id: 'a1', + role: 'assistant', + content: 'Checking.', + toolCalls: [ + { + id: 'tc-1', + type: 'function', + function: { name: 'getWeather', arguments: '{"loc":"Berlin"}' }, + }, + ], + }, + { + id: 't1', + role: 'tool', + content: '{"temp":72}', + toolCallId: 'tc-1', + ...(metadata !== undefined && { metadata }), + }, + ] + + processor.processChunk({ + type: EventType.MESSAGES_SNAPSHOT, + messages: snapshotMessages(), + timestamp: Date.now(), + } as unknown as StreamChunk) + + const getToolResult = () => + processor + .getMessages() + .flatMap((message) => message.parts) + .find( + (part): part is ToolResultPart => + part.type === 'tool-result' && part.toolCallId === 'tc-1', + ) + + expect(getToolResult()?.metadata).toEqual(streamedMetadata) + + const snapshotMetadata = { + displayType: 'snapshot-account-assets', + accountId: 'A002', + } + processor.processChunk({ + type: EventType.MESSAGES_SNAPSHOT, + messages: snapshotMessages(snapshotMetadata), + timestamp: Date.now(), + } as unknown as StreamChunk) + + expect(getToolResult()?.metadata).toEqual(snapshotMetadata) + }) + it('anchors a tool message that also has a valid UI resource', () => { const processor = new StreamProcessor() const uiResource = { From 12f21b9859c643d042be2b86e023f456398bed2d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:18:30 +1100 Subject: [PATCH 12/13] Fix: drop a stale tool-result outcome on snapshot restore Drop toolResultOutcome when a successful snapshot row restores earlier metadata. Cover denial followed by a snapshot that omits metadata. --- .../chat/stream/message-updaters.ts | 51 ++++++++-------- .../src/activities/chat/stream/processor.ts | 8 ++- packages/ai/tests/stream-processor.test.ts | 59 +++++++++++++++++++ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/packages/ai/src/activities/chat/stream/message-updaters.ts b/packages/ai/src/activities/chat/stream/message-updaters.ts index 72bd3fbb18..edf4edb58c 100644 --- a/packages/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/ai/src/activities/chat/stream/message-updaters.ts @@ -6,7 +6,10 @@ */ import { isRedactedThinkingId } from '../../../utilities/reasoning-encrypted-value' -import { mergeMetadata } from '../../../utilities/merge-metadata' +import { + mergeMetadata, + tanstackMetadata, +} from '../../../utilities/merge-metadata' import { parsePartialJSON } from './json-parser' import type { ContentPart, @@ -110,6 +113,23 @@ export function updateToolCallPart( }) } +/** Drop a control outcome left on metadata after the result itself succeeded. */ +export function withoutStaleToolResultOutcome( + metadata: Record | undefined, +): Record | undefined { + if (metadata == null) return undefined + const tanstack = tanstackMetadata({ metadata }) + if (tanstack?.toolResultOutcome === undefined) return metadata + const { toolResultOutcome: _staleOutcome, ...restTanstack } = tanstack + const nextMetadata = { ...metadata } + if (Object.keys(restTanstack).length > 0) { + nextMetadata.tanstack = restTanstack + } else { + delete nextMetadata.tanstack + } + return Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined +} + /** * Update or add a tool result part to a message. */ @@ -141,13 +161,8 @@ export function updateToolResultPart( // Preserve existing metadata when a later result update does not include // metadata, and merge partial metadata (including metadata.tanstack). let mergedMetadata = mergeMetadata(existingResultPart?.metadata, metadata) - const incomingTanstack = metadata?.tanstack const hasIncomingToolResultOutcome = - incomingTanstack != null && - typeof incomingTanstack === 'object' && - !Array.isArray(incomingTanstack) && - 'toolResultOutcome' in incomingTanstack && - incomingTanstack.toolResultOutcome !== undefined + tanstackMetadata({ metadata })?.toolResultOutcome !== undefined // A successful retry supersedes a previous denial/cancellation. Do not // carry the old control outcome into the new result, or snapshot hydration @@ -155,27 +170,9 @@ export function updateToolResultPart( if ( state === 'complete' && outcome === undefined && - !hasIncomingToolResultOutcome && - mergedMetadata + !hasIncomingToolResultOutcome ) { - const tanstack = mergedMetadata.tanstack - if ( - tanstack != null && - typeof tanstack === 'object' && - !Array.isArray(tanstack) && - 'toolResultOutcome' in tanstack - ) { - const { toolResultOutcome: _staleOutcome, ...restTanstack } = - tanstack as Record - const nextMetadata = { ...mergedMetadata } - if (Object.keys(restTanstack).length > 0) { - nextMetadata.tanstack = restTanstack - } else { - delete nextMetadata.tanstack - } - mergedMetadata = - Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined - } + mergedMetadata = withoutStaleToolResultOutcome(mergedMetadata) } const toolResultPart: ToolResultPart = { diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index f91c70986e..a552040067 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -58,6 +58,7 @@ import { updateToolCallPart, updateToolCallWithOutput, updateToolResultPart, + withoutStaleToolResultOutcome, } from './message-updaters' import { ImmediateStrategy } from './strategies' import { INTERRUPT_BINDING_METADATA_KEY } from '../../../interrupt-resume' @@ -2126,8 +2127,13 @@ export class StreamProcessor { if (part.type !== 'tool-result' || part.metadata != null) return part const previous = prevToolResults.get(part.toolCallId) if (previous?.metadata == null) return part + const metadata = + part.state === 'complete' && part.outcome === undefined + ? withoutStaleToolResultOutcome(previous.metadata) + : previous.metadata + if (metadata == null) return part restoredToolResultMetadata = true - return { ...part, metadata: previous.metadata } + return { ...part, metadata } }) const snapshotMessage = restoredToolResultMetadata ? { ...msg, parts: snapshotParts } diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index feaa8c9732..1a2e8a76b1 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -3968,6 +3968,65 @@ describe('StreamProcessor', () => { expect(getToolResult()?.metadata).toEqual(snapshotMetadata) }) + it('drops a stale denial when a snapshot success omits metadata', () => { + const processor = new StreamProcessor() + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart('a1')) + processor.processChunk(ev.toolStart('tc-1', 'getWeather')) + processor.processChunk( + chunk(EventType.TOOL_CALL_RESULT, { + messageId: 't1', + toolCallId: 'tc-1', + content: '{"error":"User declined tool execution"}', + role: 'tool', + metadata: { + displayType: 'account-assets', + tanstack: { toolResultOutcome: 'denied', retained: true }, + }, + }), + ) + processor.processChunk(ev.runFinished('tool_calls')) + processor.processChunk({ + type: EventType.MESSAGES_SNAPSHOT, + messages: [ + { + id: 'a1', + role: 'assistant', + content: 'Checking.', + toolCalls: [ + { + id: 'tc-1', + type: 'function', + function: { name: 'getWeather', arguments: '{}' }, + }, + ], + }, + { + id: 't1', + role: 'tool', + content: '{"temp":72}', + toolCallId: 'tc-1', + }, + ], + timestamp: Date.now(), + } as unknown as StreamChunk) + + const toolResult = processor + .getMessages() + .flatMap((message) => message.parts) + .find( + (part): part is ToolResultPart => + part.type === 'tool-result' && part.toolCallId === 'tc-1', + ) + expect(toolResult?.state).toBe('complete') + expect(toolResult?.outcome).toBeUndefined() + expect(toolResult?.metadata).toEqual({ + displayType: 'account-assets', + tanstack: { retained: true }, + }) + }) + it('anchors a tool message that also has a valid UI resource', () => { const processor = new StreamProcessor() const uiResource = { From 90e2a11ba0e8355e901b6367bfbb7b9f316ba08b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:20:41 +0000 Subject: [PATCH 13/13] ci: apply automated fixes --- .changeset/wacky-gifts-show.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wacky-gifts-show.md b/.changeset/wacky-gifts-show.md index 17a75bb6f2..71d0887086 100644 --- a/.changeset/wacky-gifts-show.md +++ b/.changeset/wacky-gifts-show.md @@ -1,5 +1,5 @@ --- -"@tanstack/ai": patch +'@tanstack/ai': patch --- Preserve metadata from live AG-UI TOOL_CALL_RESULT events on ToolResultPart.