From a73bdc2cab4fc2a076541fc9b6ca4dee1a88b631 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 20 May 2026 12:28:44 +0200 Subject: [PATCH 1/8] fix: complete tool calls with server results --- .changeset/server-tool-results-complete.md | 7 ++ packages/typescript/ai-client/src/types.ts | 1 + .../typescript/ai-event-client/src/index.ts | 1 + .../ai/src/activities/chat/messages.ts | 1 + .../chat/stream/message-updaters.ts | 6 +- .../src/activities/chat/stream/processor.ts | 1 + packages/typescript/ai/src/types.ts | 1 + .../ai/tests/message-updaters.test.ts | 9 ++- .../ai/tests/stream-processor.test.ts | 4 + testing/e2e/tests/tools-test/helpers.ts | 15 ++++ .../tools-test/server-client-sequence.spec.ts | 73 ++++++++++++++++++- 11 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 .changeset/server-tool-results-complete.md diff --git a/.changeset/server-tool-results-complete.md b/.changeset/server-tool-results-complete.md new file mode 100644 index 000000000..0b97fbd43 --- /dev/null +++ b/.changeset/server-tool-results-complete.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-client': patch +'@tanstack/ai-event-client': patch +--- + +Populate server-executed tool results on the matching `tool-call` part and mark successful tool calls as `complete`. diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index 815d6a838..1159150c1 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -25,6 +25,7 @@ export type ToolCallState = | 'input-complete' // All arguments received | 'approval-requested' // Waiting for user approval | 'approval-responded' // User has approved/denied + | 'complete' // Result is complete /** * Tool result states - track the lifecycle of a tool result diff --git a/packages/typescript/ai-event-client/src/index.ts b/packages/typescript/ai-event-client/src/index.ts index 17fa1d6e0..a4169c13c 100644 --- a/packages/typescript/ai-event-client/src/index.ts +++ b/packages/typescript/ai-event-client/src/index.ts @@ -112,6 +112,7 @@ export type ToolCallState = | 'input-complete' // All arguments received | 'approval-requested' // Waiting for user approval | 'approval-responded' // User has approved/denied + | 'complete' // Result is complete /** * Tool result states - track the lifecycle of a tool result diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts index b5546b7ca..c8fe54026 100644 --- a/packages/typescript/ai/src/activities/chat/messages.ts +++ b/packages/typescript/ai/src/activities/chat/messages.ts @@ -200,6 +200,7 @@ function createSegment(): AssistantSegment { function isToolCallIncluded(part: ToolCallPart): boolean { return ( part.state === 'input-complete' || + part.state === 'complete' || part.state === 'approval-responded' || part.output !== undefined ) diff --git a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts index e6d18f0da..3fd8895f3 100644 --- a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts @@ -217,11 +217,7 @@ export function updateToolCallWithOutput( if (toolCallPart) { toolCallPart.output = errorText ? { error: errorText } : output - if (state) { - toolCallPart.state = state - } else { - toolCallPart.state = 'input-complete' - } + toolCallPart.state = state ?? (errorText ? 'input-complete' : 'complete') } return { ...msg, parts } diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index d1ee72d77..f51946338 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -378,6 +378,7 @@ export class StreamProcessor { // 3. It has a corresponding tool-result part (server tool completed) return toolParts.every( (part) => + part.state === 'complete' || part.state === 'approval-responded' || (part.output !== undefined && !part.approval) || toolResultIds.has(part.id), diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index a12964981..9566cb32f 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -40,6 +40,7 @@ export type ToolCallState = | 'input-complete' // All arguments received | 'approval-requested' // Waiting for user approval | 'approval-responded' // User has approved/denied + | 'complete' // Result is complete /** * Tool result states - track the lifecycle of a tool result diff --git a/packages/typescript/ai/tests/message-updaters.test.ts b/packages/typescript/ai/tests/message-updaters.test.ts index efae2cf3a..358cb2b3d 100644 --- a/packages/typescript/ai/tests/message-updaters.test.ts +++ b/packages/typescript/ai/tests/message-updaters.test.ts @@ -538,7 +538,7 @@ describe('message-updaters', () => { }) describe('updateToolCallWithOutput', () => { - it('should update tool call with output', () => { + it('should update tool call with output and complete state', () => { const messages = [ createMessage('msg-1', 'assistant', [ { @@ -558,7 +558,7 @@ describe('message-updaters', () => { id: 'call-1', name: 'getWeather', arguments: '{}', - state: 'input-complete', + state: 'complete', output, }) }) @@ -588,7 +588,7 @@ describe('message-updaters', () => { expect(part?.output).toEqual(output) }) - it('should default to input-complete state when not provided', () => { + it('should default to complete state when not provided', () => { const messages = [ createMessage('msg-1', 'assistant', [ { @@ -604,7 +604,7 @@ describe('message-updaters', () => { const result = updateToolCallWithOutput(messages, 'call-1', output) const part = result[0]?.parts[0] as ToolCallPart | undefined - expect(part?.state).toBe('input-complete') + expect(part?.state).toBe('complete') }) it('should handle error output', () => { @@ -629,6 +629,7 @@ describe('message-updaters', () => { const part = result[0]?.parts[0] as ToolCallPart | undefined expect(part?.output).toEqual({ error: 'Tool execution failed' }) + expect(part?.state).toBe('input-complete') }) it('should search across all messages', () => { diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 7b4dbeaa1..2e1f613bd 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -917,6 +917,7 @@ describe('StreamProcessor', () => { (p) => p.type === 'tool-call', ) as ToolCallPart expect((toolCallPart as any).output).toEqual({ temp: 72 }) + expect(toolCallPart.state).toBe('complete') const toolResultPart = messages[0]?.parts.find( (p) => p.type === 'tool-result', @@ -981,6 +982,7 @@ describe('StreamProcessor', () => { (p) => p.type === 'tool-call', ) as ToolCallPart expect((toolCallPart as any).output).toEqual({ temp: 72 }) + expect(toolCallPart.state).toBe('complete') const toolResultPart = messages[0]!.parts.find( (p) => p.type === 'tool-result', @@ -1020,6 +1022,7 @@ describe('StreamProcessor', () => { .getMessages()[0]! .parts.find((p) => p.type === 'tool-call') as ToolCallPart expect((toolCallPart as any).output).toEqual({ error: 'Network error' }) + expect(toolCallPart.state).toBe('input-complete') const toolResultPart = processor .getMessages()[0]! @@ -3326,6 +3329,7 @@ describe('StreamProcessor', () => { (p) => p.type === 'tool-call', ) as ToolCallPart expect((toolCallPart as any).output).toEqual({ temp: 72 }) + expect(toolCallPart.state).toBe('complete') const toolResultPart = messages[0]?.parts.find( (p) => p.type === 'tool-result', diff --git a/testing/e2e/tests/tools-test/helpers.ts b/testing/e2e/tests/tools-test/helpers.ts index a83853cd9..4388ca80b 100644 --- a/testing/e2e/tests/tools-test/helpers.ts +++ b/testing/e2e/tests/tools-test/helpers.ts @@ -180,6 +180,21 @@ export async function getToolCalls( }) } +/** + * Parse the full UIMessage array from #messages-json-content. + */ +export async function getMessages(page: Page): Promise> { + return page.evaluate(() => { + const el = document.getElementById('messages-json-content') + if (!el) return [] + try { + return JSON.parse(el.textContent || '[]') + } catch { + return [] + } + }) +} + /** * Extract tool-call parts with parsed arguments from #messages-json-content. */ diff --git a/testing/e2e/tests/tools-test/server-client-sequence.spec.ts b/testing/e2e/tests/tools-test/server-client-sequence.spec.ts index e0f136ed8..a7c64426c 100644 --- a/testing/e2e/tests/tools-test/server-client-sequence.spec.ts +++ b/testing/e2e/tests/tools-test/server-client-sequence.spec.ts @@ -6,6 +6,7 @@ import { getMetadata, getEventLog, getToolCalls, + getMessages, } from './helpers' /** @@ -58,6 +59,73 @@ test.describe('Server-Client Sequence E2E Tests', () => { expect(chartExecution).toBeTruthy() }) + test('server and client tool results populate tool-call output and complete state', async ({ + page, + testId, + aimockPort, + }) => { + await selectScenario(page, 'sequence-server-client', testId, aimockPort) + await runTest(page) + + await waitForTestComplete(page, 15000, 2) + + await page.waitForFunction( + () => { + const messagesEl = document.getElementById('messages-json-content') + const messages = JSON.parse(messagesEl?.textContent || '[]') + const toolCalls = messages.flatMap((msg: any) => + (msg.parts || []).filter((part: any) => part.type === 'tool-call'), + ) + + const fetchData = toolCalls.find( + (part: any) => part.name === 'fetch_data', + ) + const displayChart = toolCalls.find( + (part: any) => part.name === 'display_chart', + ) + + return ( + fetchData?.state === 'complete' && + fetchData.output !== undefined && + displayChart?.state === 'complete' && + displayChart.output !== undefined + ) + }, + { timeout: 10000 }, + ) + + const messages = await getMessages(page) + const toolCalls = messages.flatMap((msg) => + (msg.parts || []).filter((part: any) => part.type === 'tool-call'), + ) + const toolResults = messages.flatMap((msg) => + (msg.parts || []).filter((part: any) => part.type === 'tool-result'), + ) + + const fetchData = toolCalls.find((part) => part.name === 'fetch_data') + expect(fetchData).toMatchObject({ + state: 'complete', + output: { + source: 'api', + data: [1, 2, 3, 4, 5], + }, + }) + + const displayChart = toolCalls.find((part) => part.name === 'display_chart') + expect(displayChart?.state).toBe('complete') + expect(displayChart?.output).toMatchObject({ rendered: true }) + expect(typeof displayChart?.output?.chartId).toBe('string') + + expect( + toolResults.some( + (part) => + part.toolCallId === fetchData?.id && + part.state === 'complete' && + JSON.parse(part.content).source === 'api', + ), + ).toBe(true) + }) + test('server then two client tools in sequence', async ({ page, testId, @@ -162,10 +230,7 @@ test.describe('Server-Client Sequence E2E Tests', () => { const toolCalls = await getToolCalls(page) const weatherTool = toolCalls.find((tc) => tc.name === 'get_weather') expect(weatherTool).toBeTruthy() - // Server tools stay at 'input-complete' state but are tracked as complete via tool-result parts - expect(['complete', 'input-complete', 'output-available']).toContain( - weatherTool?.state, - ) + expect(weatherTool?.state).toBe('complete') }) test('text only scenario has no tool calls', async ({ From 4b3601529dba0096b104764d3de3f6d2129c45a9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 20 May 2026 12:41:24 +0200 Subject: [PATCH 2/8] fix: hydrate server tool outputs from history --- .../ai/src/activities/chat/messages.ts | 21 ++++++++++++++++++- .../ai/tests/message-converters.test.ts | 21 ++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts index c8fe54026..d3f35cd9b 100644 --- a/packages/typescript/ai/src/activities/chat/messages.ts +++ b/packages/typescript/ai/src/activities/chat/messages.ts @@ -32,6 +32,14 @@ function safeJsonStringify(value: unknown): string { } } +function parseToolResultContent(content: string): unknown { + try { + return JSON.parse(content) + } catch { + return content + } +} + /** * Collapse an array of ContentParts into the most compact ModelMessage content: * - Empty array → null @@ -468,10 +476,21 @@ export function modelMessagesToUIMessages( currentAssistantMessage && currentAssistantMessage.role === 'assistant' ) { + const content = getTextContent(msg.content) + const toolCallPart = currentAssistantMessage.parts.find( + (part): part is ToolCallPart => + part.type === 'tool-call' && part.id === msg.toolCallId, + ) + + if (toolCallPart) { + toolCallPart.output = parseToolResultContent(content) + toolCallPart.state = 'complete' + } + currentAssistantMessage.parts.push({ type: 'tool-result', toolCallId: msg.toolCallId, - content: getTextContent(msg.content), + content, state: 'complete', }) } else { diff --git a/packages/typescript/ai/tests/message-converters.test.ts b/packages/typescript/ai/tests/message-converters.test.ts index 9264d96b7..073868fd3 100644 --- a/packages/typescript/ai/tests/message-converters.test.ts +++ b/packages/typescript/ai/tests/message-converters.test.ts @@ -919,7 +919,8 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', - state: 'input-complete', + state: 'complete', + output: { temp: 72 }, }, { type: 'tool-result', @@ -978,7 +979,8 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getGuitars', arguments: '', - state: 'input-complete', + state: 'complete', + output: [{ id: 7 }], }, { type: 'tool-result', @@ -995,7 +997,8 @@ describe('Message Converters', () => { id: 'tc-2', name: 'recommend', arguments: '{"id":7}', - state: 'input-complete', + state: 'complete', + output: { recommended: true }, }, { type: 'tool-result', @@ -1083,7 +1086,8 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', - state: 'input-complete', + state: 'complete', + output: { temp: 72 }, }) expect(assistantParts).toContainEqual({ type: 'tool-result', @@ -1222,7 +1226,8 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', - state: 'input-complete', + state: 'complete', + output: { temp: 72 }, }, { type: 'tool-result', @@ -1314,7 +1319,8 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getGuitars', arguments: '', - state: 'input-complete', + state: 'complete', + output: [{ id: 7 }], }, { type: 'tool-result', @@ -1332,7 +1338,8 @@ describe('Message Converters', () => { id: 'tc-2', name: 'recommend', arguments: '{"id":7}', - state: 'input-complete', + state: 'complete', + output: { recommended: true }, }, { type: 'tool-result', From 82214d4ea0d2cd552982a9a02d02b35b211b50a2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 20 May 2026 13:00:36 +0200 Subject: [PATCH 3/8] test: cover server tool history hydration --- testing/e2e/src/routes/tools-test.tsx | 52 +++++++++++++-- .../tools-test/server-tool-history.spec.ts | 65 +++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 testing/e2e/tests/tools-test/server-tool-history.spec.ts diff --git a/testing/e2e/src/routes/tools-test.tsx b/testing/e2e/src/routes/tools-test.tsx index 893c55168..30f3ad55e 100644 --- a/testing/e2e/src/routes/tools-test.tsx +++ b/testing/e2e/src/routes/tools-test.tsx @@ -1,7 +1,11 @@ -import { useState, useCallback, useRef, useEffect } from 'react' +import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' -import { toolDefinition } from '@tanstack/ai' +import { + modelMessagesToUIMessages, + toolDefinition, + type ModelMessage, +} from '@tanstack/ai' import { z } from 'zod' import { SCENARIO_LIST } from '@/lib/tools-test-tools' @@ -98,8 +102,38 @@ function createTrackedTools( return [showNotificationTool, displayChartTool] } +function createHistoryFixtureMessages(historyFixture?: string) { + if (historyFixture !== 'server-tool-result') { + return [] + } + + const modelMessages: Array = [ + { + role: 'assistant', + content: 'Let me check the weather.', + toolCalls: [ + { + id: 'history-tc-1', + type: 'function', + function: { + name: 'getWeather', + arguments: '{"city":"NYC"}', + }, + }, + ], + }, + { + role: 'tool', + content: '{"temp":72,"condition":"sunny"}', + toolCallId: 'history-tc-1', + }, + ] + + return modelMessagesToUIMessages(modelMessages) +} + function ToolsTestPage() { - const { testId, aimockPort } = Route.useSearch() + const { testId, aimockPort, historyFixture } = Route.useSearch() const [scenario, setScenario] = useState('text-only') const [toolEvents, setToolEvents] = useState>([]) const [testStartTime, setTestStartTime] = useState(null) @@ -115,6 +149,10 @@ function ToolsTestPage() { // Create tracked tools (memoized since addEvent is stable) const clientTools = useRef(createTrackedTools(addEvent)).current + const initialMessages = useMemo( + () => createHistoryFixtureMessages(historyFixture), + [historyFixture], + ) const { messages, @@ -125,8 +163,9 @@ function ToolsTestPage() { error, } = useChat({ // Include scenario in ID so client is recreated when scenario changes - id: `tools-test-${scenario}`, + id: `tools-test-${scenario}-${historyFixture || 'empty'}`, connection: fetchServerSentEvents('/api/tools-test'), + initialMessages, body: { scenario, testId, aimockPort }, tools: clientTools, onFinish: () => { @@ -565,6 +604,7 @@ function ToolsTestPage() { id="test-metadata" style={{ display: 'none' }} data-scenario={scenario} + data-history-fixture={historyFixture || ''} data-is-loading={isLoading.toString()} data-test-complete={testComplete.toString()} data-tool-call-count={toolCalls.length} @@ -633,6 +673,10 @@ export const Route = createFileRoute('/tools-test')({ return { testId: typeof search.testId === 'string' ? search.testId : undefined, aimockPort: port != null && !isNaN(port) ? port : undefined, + historyFixture: + typeof search.historyFixture === 'string' + ? search.historyFixture + : undefined, } }, }) diff --git a/testing/e2e/tests/tools-test/server-tool-history.spec.ts b/testing/e2e/tests/tools-test/server-tool-history.spec.ts new file mode 100644 index 000000000..2b25a774d --- /dev/null +++ b/testing/e2e/tests/tools-test/server-tool-history.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../fixtures' +import { getMessages } from './helpers' + +test.describe('Server Tool History Hydration', () => { + test('hydrates server tool-call output from a matching tool result', async ({ + page, + testId, + aimockPort, + }) => { + const params = new URLSearchParams({ + historyFixture: 'server-tool-result', + testId, + aimockPort: String(aimockPort), + }) + + await page.goto(`/tools-test?${params.toString()}`) + await page.waitForSelector('#run-test-button') + + await page.waitForFunction( + () => { + const messagesEl = document.getElementById('messages-json-content') + const messages = JSON.parse(messagesEl?.textContent || '[]') + const toolCall = messages + .flatMap((msg: any) => msg.parts || []) + .find( + (part: any) => + part.type === 'tool-call' && part.id === 'history-tc-1', + ) + + return toolCall?.state === 'complete' && toolCall.output !== undefined + }, + { timeout: 10000 }, + ) + + const messages = await getMessages(page) + const assistant = messages.find((message) => message.role === 'assistant') + const toolCall = assistant?.parts.find( + (part: any) => part.type === 'tool-call' && part.id === 'history-tc-1', + ) + const toolResult = assistant?.parts.find( + (part: any) => + part.type === 'tool-result' && + part.toolCallId === 'history-tc-1', + ) + + expect(toolCall).toMatchObject({ + type: 'tool-call', + id: 'history-tc-1', + name: 'getWeather', + arguments: '{"city":"NYC"}', + state: 'complete', + output: { + temp: 72, + condition: 'sunny', + }, + }) + + expect(toolResult).toMatchObject({ + type: 'tool-result', + toolCallId: 'history-tc-1', + content: '{"temp":72,"condition":"sunny"}', + state: 'complete', + }) + }) +}) From e6b6381ed5a7bd100e92fd916bb280999c3e7279 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 11:02:42 +0000 Subject: [PATCH 4/8] ci: apply automated fixes --- testing/e2e/tests/tools-test/server-tool-history.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/e2e/tests/tools-test/server-tool-history.spec.ts b/testing/e2e/tests/tools-test/server-tool-history.spec.ts index 2b25a774d..40e558eb6 100644 --- a/testing/e2e/tests/tools-test/server-tool-history.spec.ts +++ b/testing/e2e/tests/tools-test/server-tool-history.spec.ts @@ -39,8 +39,7 @@ test.describe('Server Tool History Hydration', () => { ) const toolResult = assistant?.parts.find( (part: any) => - part.type === 'tool-result' && - part.toolCallId === 'history-tc-1', + part.type === 'tool-result' && part.toolCallId === 'history-tc-1', ) expect(toolCall).toMatchObject({ From 18790cf56b5d740e616eee034eccd3dfa2cd4457 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 20 May 2026 13:08:39 +0200 Subject: [PATCH 5/8] test: add issue 176 manual repro page --- .../ts-react-chat/src/components/Header.tsx | 14 ++ examples/ts-react-chat/src/routeTree.gen.ts | 21 +++ .../src/routes/issue-176-tool-result.tsx | 152 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 examples/ts-react-chat/src/routes/issue-176-tool-result.tsx diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index d0b02b740..fb2cceab5 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' import { Braces, + Bug, FileAudio, FileText, Guitar, @@ -210,6 +211,19 @@ export default function Header() { Voice Chat (Realtime) + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Issue #176 Repro + diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 45a3107ea..d0966d88a 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RealtimeRouteImport } from './routes/realtime' +import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' @@ -38,6 +39,11 @@ const RealtimeRoute = RealtimeRouteImport.update({ path: '/realtime', getParentRoute: () => rootRouteImport, } as any) +const Issue176ToolResultRoute = Issue176ToolResultRouteImport.update({ + id: '/issue-176-tool-result', + path: '/issue-176-tool-result', + getParentRoute: () => rootRouteImport, +} as any) const ImageGenRoute = ImageGenRouteImport.update({ id: '/image-gen', path: '/image-gen', @@ -155,6 +161,7 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/issue-176-tool-result': typeof Issue176ToolResultRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -180,6 +187,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/issue-176-tool-result': typeof Issue176ToolResultRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -206,6 +214,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/issue-176-tool-result': typeof Issue176ToolResultRoute '/realtime': typeof RealtimeRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -233,6 +242,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/image-gen' + | '/issue-176-tool-result' | '/realtime' | '/api/image-gen' | '/api/structured-chat' @@ -258,6 +268,7 @@ export interface FileRouteTypes { to: | '/' | '/image-gen' + | '/issue-176-tool-result' | '/realtime' | '/api/image-gen' | '/api/structured-chat' @@ -283,6 +294,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/image-gen' + | '/issue-176-tool-result' | '/realtime' | '/api/image-gen' | '/api/structured-chat' @@ -309,6 +321,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImageGenRoute: typeof ImageGenRoute + Issue176ToolResultRoute: typeof Issue176ToolResultRoute RealtimeRoute: typeof RealtimeRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiStructuredChatRoute: typeof ApiStructuredChatRoute @@ -341,6 +354,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RealtimeRouteImport parentRoute: typeof rootRouteImport } + '/issue-176-tool-result': { + id: '/issue-176-tool-result' + path: '/issue-176-tool-result' + fullPath: '/issue-176-tool-result' + preLoaderRoute: typeof Issue176ToolResultRouteImport + parentRoute: typeof rootRouteImport + } '/image-gen': { id: '/image-gen' path: '/image-gen' @@ -501,6 +521,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImageGenRoute: ImageGenRoute, + Issue176ToolResultRoute: Issue176ToolResultRoute, RealtimeRoute: RealtimeRoute, ApiImageGenRoute: ApiImageGenRoute, ApiStructuredChatRoute: ApiStructuredChatRoute, diff --git a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx new file mode 100644 index 000000000..abe71fd77 --- /dev/null +++ b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx @@ -0,0 +1,152 @@ +import { useMemo } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { modelMessagesToUIMessages, type ModelMessage } from '@tanstack/ai' + +const modelMessages: Array = [ + { + role: 'assistant', + content: 'Let me check the weather.', + toolCalls: [ + { + id: 'issue-176-tool-call', + type: 'function', + function: { + name: 'getWeather', + arguments: '{"city":"NYC"}', + }, + }, + ], + }, + { + role: 'tool', + content: '{"temp":72,"condition":"sunny"}', + toolCallId: 'issue-176-tool-call', + }, +] + +function Issue176ToolResultRepro() { + const initialMessages = useMemo( + () => modelMessagesToUIMessages(modelMessages), + [], + ) + + const { messages } = useChat({ + id: 'issue-176-tool-result-repro', + connection: fetchServerSentEvents('/api/tanchat'), + initialMessages, + }) + + const toolCall = messages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === 'tool-call' && part.id === 'issue-176-tool-call', + ) + const toolResult = messages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === 'tool-result' && + part.toolCallId === 'issue-176-tool-call', + ) + const isFixed = + toolCall?.type === 'tool-call' && + toolCall.state === 'complete' && + toolCall.output !== undefined + + return ( +
+
+
+

+ Issue #176 manual repro +

+

+ Server tool result hydration +

+

+ This page initializes a chat from model-message history containing + an assistant server tool call followed by a matching tool result. + The original tool-call part should be complete and include output. +

+
+ +
+
+
+ Tool-call state +
+
+ {toolCall?.type === 'tool-call' ? toolCall.state : 'missing'} +
+
+ +
+
+ Tool-call output +
+
+ {toolCall?.type === 'tool-call' && toolCall.output !== undefined + ? 'present' + : 'missing'} +
+
+ +
+
+ Tool-result part +
+
+ {toolResult ? 'present' : 'missing'} +
+
+
+ +
+
+
+ ModelMessage history fixture +
+
+              {JSON.stringify(modelMessages, null, 2)}
+            
+
+ +
+
+ Hydrated UIMessage.parts +
+
+              {JSON.stringify(messages, null, 2)}
+            
+
+
+
+
+ ) +} + +export const Route = createFileRoute('/issue-176-tool-result')({ + component: Issue176ToolResultRepro, +}) From 5e0e76122c571ad8440e903ad4e9ad602cbb68da Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 11:14:20 +0000 Subject: [PATCH 6/8] ci: apply automated fixes --- examples/ts-react-chat/src/routes/issue-176-tool-result.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx index abe71fd77..5c77d5477 100644 --- a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx +++ b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx @@ -40,8 +40,7 @@ function Issue176ToolResultRepro() { const toolCall = messages .flatMap((message) => message.parts) .find( - (part) => - part.type === 'tool-call' && part.id === 'issue-176-tool-call', + (part) => part.type === 'tool-call' && part.id === 'issue-176-tool-call', ) const toolResult = messages .flatMap((message) => message.parts) From ca4db20c61ac66386817b9387b451aa21163508e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 20 May 2026 13:24:11 +0200 Subject: [PATCH 7/8] test: add live issue 176 repro flow --- .../src/routes/issue-176-tool-result.tsx | 151 +++++++++++++++++- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx index 5c77d5477..0eac5c48d 100644 --- a/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx +++ b/examples/ts-react-chat/src/routes/issue-176-tool-result.tsx @@ -1,7 +1,9 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' import { modelMessagesToUIMessages, type ModelMessage } from '@tanstack/ai' +import { recommendGuitarToolDef } from '@/lib/guitar-tools' const modelMessages: Array = [ { @@ -26,23 +28,49 @@ const modelMessages: Array = [ ] function Issue176ToolResultRepro() { + const [prompt, setPrompt] = useState( + 'I want an acoustic guitar recommendation. Use the required tools.', + ) const initialMessages = useMemo( () => modelMessagesToUIMessages(modelMessages), [], ) + const liveTools = useMemo( + () => + clientTools( + recommendGuitarToolDef.client(({ id }) => ({ + id: Number(id), + })), + ), + [], + ) - const { messages } = useChat({ + const { messages: fixtureMessages } = useChat({ id: 'issue-176-tool-result-repro', connection: fetchServerSentEvents('/api/tanchat'), initialMessages, }) + const { + messages: liveMessages, + sendMessage, + isLoading, + error, + } = useChat({ + id: 'issue-176-live-tool-result-repro', + connection: fetchServerSentEvents('/api/tanchat'), + tools: liveTools, + body: { + provider: 'openai', + model: 'gpt-4o', + }, + }) - const toolCall = messages + const toolCall = fixtureMessages .flatMap((message) => message.parts) .find( (part) => part.type === 'tool-call' && part.id === 'issue-176-tool-call', ) - const toolResult = messages + const toolResult = fixtureMessages .flatMap((message) => message.parts) .find( (part) => @@ -53,6 +81,21 @@ function Issue176ToolResultRepro() { toolCall?.type === 'tool-call' && toolCall.state === 'complete' && toolCall.output !== undefined + const liveServerToolCall = liveMessages + .flatMap((message) => message.parts) + .find((part) => part.type === 'tool-call' && part.name === 'getGuitars') + const liveServerToolResult = liveMessages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === 'tool-result' && + liveServerToolCall?.type === 'tool-call' && + part.toolCallId === liveServerToolCall.id, + ) + const isLiveFixed = + liveServerToolCall?.type === 'tool-call' && + liveServerToolCall.state === 'complete' && + liveServerToolCall.output !== undefined return (
@@ -71,6 +114,92 @@ function Issue176ToolResultRepro() {

+
+
+

Live LLM repro

+
+
+
+