From f27c9a62d5fa43783dcda4259160019be101c1b3 Mon Sep 17 00:00:00 2001 From: iRanadheer Date: Sun, 30 Aug 2026 14:40:23 +0000 Subject: [PATCH 1/3] fix(ai): keep the first text delta when the text starts before the tool call The first delta after a tool call cannot enter the isNewSegment branch, since previousSegment is empty at that point. The flag survived into the second delta, which then matched all three conditions: the accumulation reset and updateTextPart wrote the remainder over the text part the first delta had created. TEXT_MESSAGE_START. This covers the other order, where the text starts first. --- .../src/activities/chat/stream/processor.ts | 6 +++++ packages/ai/tests/stream-processor.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index d19da00826..6488495821 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -1357,6 +1357,12 @@ export class StreamProcessor { state.hasToolCallsSinceTextStart = false } + // A segment begins at its first delta, not its second. Left set, the flag + // makes the second delta look like a further segment: the accumulation + // resets and updateTextPart writes over the part the first delta created. + // A later tool call sets it again in handleToolCallStartEvent. + state.hasToolCallsSinceTextStart = false + const currentText = state.currentSegmentText const delta = chunk.delta || '' const nextText = delta !== '' ? currentText + delta : currentText diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index e3a93f3394..24676f7de2 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -4143,6 +4143,32 @@ describe('StreamProcessor', () => { const textParts = messages[0]?.parts.filter((p) => p.type === 'text') expect(textParts).toEqual([{ type: 'text', content: 'It is sunny.' }]) }) + + it('should not drop the first TEXT_MESSAGE_CONTENT delta when the text starts before the tool call (#1247)', () => { + const processor = new StreamProcessor() + + processor.processChunk(ev.textStart('msg-1')) + processor.processChunk( + chunk(EventType.TOOL_CALL_START, { + toolCallId: 'tc-1', + toolCallName: 'lookupWeather', + toolName: 'lookupWeather', + parentMessageId: 'msg-1', + }), + ) + processor.processChunk(ev.toolArgs('tc-1', '{"location":"Berlin"}')) + processor.processChunk(ev.toolEnd('tc-1', 'lookupWeather')) + // Two deltas again: the first is the one that goes missing, and only + // once a second arrives to displace it. + processor.processChunk(ev.textContent('It is ')) + processor.processChunk(ev.textContent('sunny.')) + processor.processChunk(ev.textEnd()) + processor.finalizeStream() + + const messages = processor.getMessages() + const textParts = messages[0]?.parts.filter((p) => p.type === 'text') + expect(textParts).toEqual([{ type: 'text', content: 'It is sunny.' }]) + }) }) describe('double onStreamEnd guard', () => { From 10411c9af0fcf09c9f92286499875db85a70f1cd Mon Sep 17 00:00:00 2001 From: iRanadheer Date: Sun, 30 Aug 2026 14:40:23 +0000 Subject: [PATCH 2/3] chore: add changeset --- .changeset/text-first-tool-drop.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/text-first-tool-drop.md diff --git a/.changeset/text-first-tool-drop.md b/.changeset/text-first-tool-drop.md new file mode 100644 index 0000000000..88d9b89412 --- /dev/null +++ b/.changeset/text-first-tool-drop.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai': patch +--- + +Keep the first text delta when a message speaks after a tool call and the text +arrives in more than one chunk. From 0ae844dd59b75eb47ab5588e93584a47cc4ccfba Mon Sep 17 00:00:00 2001 From: iRanadheer Date: Sun, 30 Aug 2026 14:49:44 +0000 Subject: [PATCH 3/3] test(e2e): cover the text-first order for #1247 The harness added with #1248 streams the tool call before the message's TEXT_MESSAGE_START. This one streams them the other way round, which is what providers that open the assistant message first emit. --- testing/e2e/src/routeTree.gen.ts | 42 +++++++++ .../src/routes/api.text-first-tool-wire.ts | 87 +++++++++++++++++++ testing/e2e/src/routes/text-first-tool.tsx | 41 +++++++++ testing/e2e/tests/text-first-tool.spec.ts | 16 ++++ 4 files changed, 186 insertions(+) create mode 100644 testing/e2e/src/routes/api.text-first-tool-wire.ts create mode 100644 testing/e2e/src/routes/text-first-tool.tsx create mode 100644 testing/e2e/tests/text-first-tool.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 24bc127321..56892bc3b3 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebsocketAdapterRouteImport } from './routes/websocket-adapter' import { Route as ToolsTestRouteImport } from './routes/tools-test' import { Route as ToolFirstTextRouteImport } from './routes/tool-first-text' +import { Route as TextFirstToolRouteImport } from './routes/text-first-tool' import { Route as PersistenceDurabilityRouteImport } from './routes/persistence-durability' import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' @@ -37,6 +38,7 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiToolFirstTextWireRouteImport } from './routes/api.tool-first-text-wire' import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire' +import { Route as ApiTextFirstToolWireRouteImport } from './routes/api.text-first-tool-wire' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiSandboxToolHistoryRouteImport } from './routes/api.sandbox-tool-history' import { Route as ApiSandboxFilePersistenceRouteImport } from './routes/api.sandbox-file-persistence' @@ -113,6 +115,11 @@ const ToolFirstTextRoute = ToolFirstTextRouteImport.update({ path: '/tool-first-text', getParentRoute: () => rootRouteImport, } as any) +const TextFirstToolRoute = TextFirstToolRouteImport.update({ + id: '/text-first-tool', + path: '/text-first-tool', + getParentRoute: () => rootRouteImport, +} as any) const PersistenceDurabilityRoute = PersistenceDurabilityRouteImport.update({ id: '/persistence-durability', path: '/persistence-durability', @@ -241,6 +248,11 @@ const ApiToolCallLifecycleWireRoute = path: '/api/tool-call-lifecycle-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiTextFirstToolWireRoute = ApiTextFirstToolWireRouteImport.update({ + id: '/api/text-first-tool-wire', + path: '/api/text-first-tool-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiSummarizeRoute = ApiSummarizeRouteImport.update({ id: '/api/summarize', path: '/api/summarize', @@ -577,6 +589,7 @@ export interface FileRoutesByFullPath { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute + '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -635,6 +648,7 @@ export interface FileRoutesByFullPath { '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute '/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute '/api/summarize': typeof ApiSummarizeRoute + '/api/text-first-tool-wire': typeof ApiTextFirstToolWireRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -667,6 +681,7 @@ export interface FileRoutesByTo { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute + '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -725,6 +740,7 @@ export interface FileRoutesByTo { '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute '/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute '/api/summarize': typeof ApiSummarizeRoute + '/api/text-first-tool-wire': typeof ApiTextFirstToolWireRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -758,6 +774,7 @@ export interface FileRoutesById { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/persistence-durability': typeof PersistenceDurabilityRoute + '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute '/websocket-adapter': typeof WebsocketAdapterRoute @@ -816,6 +833,7 @@ export interface FileRoutesById { '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute '/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute '/api/summarize': typeof ApiSummarizeRoute + '/api/text-first-tool-wire': typeof ApiTextFirstToolWireRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute '/api/tools-test': typeof ApiToolsTestRoute @@ -850,6 +868,7 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/persistence-durability' + | '/text-first-tool' | '/tool-first-text' | '/tools-test' | '/websocket-adapter' @@ -908,6 +927,7 @@ export interface FileRouteTypes { | '/api/sandbox-file-persistence' | '/api/sandbox-tool-history' | '/api/summarize' + | '/api/text-first-tool-wire' | '/api/tool-call-lifecycle-wire' | '/api/tool-first-text-wire' | '/api/tools-test' @@ -940,6 +960,7 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/persistence-durability' + | '/text-first-tool' | '/tool-first-text' | '/tools-test' | '/websocket-adapter' @@ -998,6 +1019,7 @@ export interface FileRouteTypes { | '/api/sandbox-file-persistence' | '/api/sandbox-tool-history' | '/api/summarize' + | '/api/text-first-tool-wire' | '/api/tool-call-lifecycle-wire' | '/api/tool-first-text-wire' | '/api/tools-test' @@ -1030,6 +1052,7 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/persistence-durability' + | '/text-first-tool' | '/tool-first-text' | '/tools-test' | '/websocket-adapter' @@ -1088,6 +1111,7 @@ export interface FileRouteTypes { | '/api/sandbox-file-persistence' | '/api/sandbox-tool-history' | '/api/summarize' + | '/api/text-first-tool-wire' | '/api/tool-call-lifecycle-wire' | '/api/tool-first-text-wire' | '/api/tools-test' @@ -1121,6 +1145,7 @@ export interface RootRouteChildren { MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute PersistenceDurabilityRoute: typeof PersistenceDurabilityRoute + TextFirstToolRoute: typeof TextFirstToolRoute ToolFirstTextRoute: typeof ToolFirstTextRoute ToolsTestRoute: typeof ToolsTestRoute WebsocketAdapterRoute: typeof WebsocketAdapterRoute @@ -1179,6 +1204,7 @@ export interface RootRouteChildren { ApiSandboxFilePersistenceRoute: typeof ApiSandboxFilePersistenceRoute ApiSandboxToolHistoryRoute: typeof ApiSandboxToolHistoryRoute ApiSummarizeRoute: typeof ApiSummarizeRoute + ApiTextFirstToolWireRoute: typeof ApiTextFirstToolWireRoute ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute ApiToolFirstTextWireRoute: typeof ApiToolFirstTextWireRoute ApiToolsTestRoute: typeof ApiToolsTestRoute @@ -1211,6 +1237,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ToolFirstTextRouteImport parentRoute: typeof rootRouteImport } + '/text-first-tool': { + id: '/text-first-tool' + path: '/text-first-tool' + fullPath: '/text-first-tool' + preLoaderRoute: typeof TextFirstToolRouteImport + parentRoute: typeof rootRouteImport + } '/persistence-durability': { id: '/persistence-durability' path: '/persistence-durability' @@ -1386,6 +1419,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiToolCallLifecycleWireRouteImport parentRoute: typeof rootRouteImport } + '/api/text-first-tool-wire': { + id: '/api/text-first-tool-wire' + path: '/api/text-first-tool-wire' + fullPath: '/api/text-first-tool-wire' + preLoaderRoute: typeof ApiTextFirstToolWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/summarize': { id: '/api/summarize' path: '/api/summarize' @@ -1886,6 +1926,7 @@ const rootRouteChildren: RootRouteChildren = { MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, PersistenceDurabilityRoute: PersistenceDurabilityRoute, + TextFirstToolRoute: TextFirstToolRoute, ToolFirstTextRoute: ToolFirstTextRoute, ToolsTestRoute: ToolsTestRoute, WebsocketAdapterRoute: WebsocketAdapterRoute, @@ -1944,6 +1985,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSandboxFilePersistenceRoute: ApiSandboxFilePersistenceRoute, ApiSandboxToolHistoryRoute: ApiSandboxToolHistoryRoute, ApiSummarizeRoute: ApiSummarizeRoute, + ApiTextFirstToolWireRoute: ApiTextFirstToolWireRoute, ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute, ApiToolFirstTextWireRoute: ApiToolFirstTextWireRoute, ApiToolsTestRoute: ApiToolsTestRoute, diff --git a/testing/e2e/src/routes/api.text-first-tool-wire.ts b/testing/e2e/src/routes/api.text-first-tool-wire.ts new file mode 100644 index 0000000000..d99309ff29 --- /dev/null +++ b/testing/e2e/src/routes/api.text-first-tool-wire.ts @@ -0,0 +1,87 @@ +import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Wire-format regression for issue #1247, in the other event order. + * + * Here the message's `TEXT_MESSAGE_START` arrives *before* the tool call, as + * providers that open the assistant message first emit it. The two + * `TEXT_MESSAGE_CONTENT` deltas after the tool result are the assertion: the + * first one is the one that used to be overwritten, and only once a second + * delta arrived to displace it. + */ +function textFirstRun( + threadId: string, + runId: string, +): AsyncIterable { + const messageId = 'msg-1' + const toolCallId = 'call-1' + return (async function* () { + yield { type: 'RUN_STARTED', threadId, runId, timestamp: Date.now() } + yield { + type: 'TEXT_MESSAGE_START', + messageId, + role: 'assistant', + timestamp: Date.now(), + } + yield { + type: 'TOOL_CALL_START', + toolCallId, + toolCallName: 'lookupWeather', + parentMessageId: messageId, + timestamp: Date.now(), + } + yield { + type: 'TOOL_CALL_ARGS', + toolCallId, + delta: '{}', + timestamp: Date.now(), + } + yield { type: 'TOOL_CALL_END', toolCallId, timestamp: Date.now() } + yield { + type: 'TOOL_CALL_RESULT', + messageId: 'tool-1', + toolCallId, + role: 'tool', + content: '{"ok":true}', + timestamp: Date.now(), + } + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId, + delta: 'Hello, ', + timestamp: Date.now(), + } + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId, + delta: 'world.', + timestamp: Date.now(), + } + yield { type: 'TEXT_MESSAGE_END', messageId, timestamp: Date.now() } + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } + })() as AsyncIterable +} + +export const Route = createFileRoute('/api/text-first-tool-wire')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: unknown = await request.json() + const threadId = + typeof body === 'object' && body !== null && 'threadId' in body + ? String((body as Record).threadId) + : 'thread-1' + const runId = `run-${threadId}` + return toServerSentEventsResponse(textFirstRun(threadId, runId)) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/text-first-tool.tsx b/testing/e2e/src/routes/text-first-tool.tsx new file mode 100644 index 0000000000..dd7a0c0ea9 --- /dev/null +++ b/testing/e2e/src/routes/text-first-tool.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +/** + * Harness page for issue #1247 in the order where the message's + * `TEXT_MESSAGE_START` precedes the tool call. `/api/text-first-tool-wire` + * streams that shape once; this page renders the resulting assistant text so + * the spec can assert the first delta was not dropped. + */ +function TextFirstToolPage() { + const { messages, sendMessage } = useChat({ + threadId: 'text-first-tool-1', + connection: fetchServerSentEvents('/api/text-first-tool-wire'), + }) + + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join('') + + useEffect(() => { + void sendMessage('go') + // Fire the single run once on mount; the harness route ignores the content. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+
{assistantText}
+
+ ) +} + +export const Route = createFileRoute('/text-first-tool')({ + component: TextFirstToolPage, +}) diff --git a/testing/e2e/tests/text-first-tool.spec.ts b/testing/e2e/tests/text-first-tool.spec.ts new file mode 100644 index 0000000000..a135d7a6cd --- /dev/null +++ b/testing/e2e/tests/text-first-tool.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' + +/** + * `StreamProcessor` must not drop the first `TEXT_MESSAGE_CONTENT` delta when + * the message's `TEXT_MESSAGE_START` precedes the tool call, the order used by + * providers that open the assistant message before calling a tool. + */ +test.describe('text-first tool call (#1247)', () => { + test('does not drop the first text delta when text starts before the tool call', async ({ + page, + }) => { + await page.goto('/text-first-tool') + + await expect(page.getByTestId('assistant-text')).toHaveText('Hello, world.') + }) +})