Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tool-first-text-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': patch
---

Fix `StreamProcessor` dropping the first `TEXT_MESSAGE_CONTENT` delta when a `TOOL_CALL_START` event's `parentMessageId` precedes that message's `TEXT_MESSAGE_START` β€” the normal AG-UI shape for "call a tool, then explain the result" as one assistant turn (#1247).
16 changes: 14 additions & 2 deletions packages/ai/src/activities/chat/stream/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,9 +900,21 @@ export class StreamProcessor {
}

// Ensure state exists
if (!this.messageStates.has(messageId)) {
this.createMessageState(messageId, uiRole)
let pendingState = this.messageStates.get(messageId)
if (!pendingState) {
pendingState = this.createMessageState(messageId, uiRole)
this.activeMessageIds.add(messageId)
} else if (pendingState.hasToolCallsSinceTextStart) {
// A tool call (e.g. TOOL_CALL_START with parentMessageId) marked
// this message before its "real" TEXT_MESSAGE_START arrived β€” same
// reset Case 2 performs, so the segment accumulator doesn't carry
// stale tool-call state into the text that follows.
if (pendingState.currentSegmentText !== pendingState.lastEmittedText) {
this.emitTextUpdateForMessage(messageId)
}
pendingState.currentSegmentText = ''
pendingState.lastEmittedText = ''
pendingState.hasToolCallsSinceTextStart = false
}

this.mergeMessageMetadata(messageId, chunk.metadata)
Expand Down
32 changes: 32 additions & 0 deletions packages/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4109,6 +4109,38 @@ describe('StreamProcessor', () => {
},
])
})

it('should not drop the first TEXT_MESSAGE_CONTENT delta in a tool-first flow (#1247)', () => {
const processor = new StreamProcessor()

processor.processChunk(
chunk(EventType.TOOL_CALL_START, {
toolCallId: 'tc-1',
toolCallName: 'lookupWeather',
toolName: 'lookupWeather',
parentMessageId: 'anthropic-msg-1',
}),
)
processor.processChunk(ev.toolArgs('tc-1', '{"location":"Berlin"}'))
processor.processChunk(ev.toolEnd('tc-1', 'lookupWeather'))

processor.processChunk(
chunk(EventType.TEXT_MESSAGE_START, {
messageId: 'anthropic-msg-1',
role: 'assistant' as const,
}),
)
// Two deltas: the bug only surfaces once a second delta arrives after
// the tool-first message's real TEXT_MESSAGE_START.
processor.processChunk(ev.textContent('It is ', 'anthropic-msg-1'))
processor.processChunk(ev.textContent('sunny.', 'anthropic-msg-1'))
processor.processChunk(ev.textEnd('anthropic-msg-1'))
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', () => {
Expand Down
68 changes: 55 additions & 13 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 PersistenceDurabilityRouteImport } from './routes/persistence-durability'
import { Route as MiddlewareTestRouteImport } from './routes/middleware-test'
import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk'
Expand All @@ -34,6 +35,7 @@ import { Route as ApiVideoRouteImport } from './routes/api.video'
import { Route as ApiTtsRouteImport } from './routes/api.tts'
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 ApiSummarizeRouteImport } from './routes/api.summarize'
import { Route as ApiSandboxToolHistoryRouteImport } from './routes/api.sandbox-tool-history'
Expand Down Expand Up @@ -80,8 +82,8 @@ import { Route as ApiDurableTakeoverRouteImport } from './routes/api.durable-tak
import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery'
import { Route as ApiDevtoolsMemoryRouteImport } from './routes/api.devtools-memory'
import { Route as ApiChatRouteImport } from './routes/api.chat'
import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat'
import { Route as ApiByteplusSeedance1080pWireRouteImport } from './routes/api.byteplus-seedance-1080p-wire'
import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat'
import { Route as ApiAudioRouteImport } from './routes/api.audio'
import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire'
import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage'
Expand All @@ -104,6 +106,11 @@ const ToolsTestRoute = ToolsTestRouteImport.update({
path: '/tools-test',
getParentRoute: () => rootRouteImport,
} as any)
const ToolFirstTextRoute = ToolFirstTextRouteImport.update({
id: '/tool-first-text',
path: '/tool-first-text',
getParentRoute: () => rootRouteImport,
} as any)
const PersistenceDurabilityRoute = PersistenceDurabilityRouteImport.update({
id: '/persistence-durability',
path: '/persistence-durability',
Expand Down Expand Up @@ -221,6 +228,11 @@ const ApiToolsTestRoute = ApiToolsTestRouteImport.update({
path: '/api/tools-test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiToolFirstTextWireRoute = ApiToolFirstTextWireRouteImport.update({
id: '/api/tool-first-text-wire',
path: '/api/tool-first-text-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiToolCallLifecycleWireRoute =
ApiToolCallLifecycleWireRouteImport.update({
id: '/api/tool-call-lifecycle-wire',
Expand Down Expand Up @@ -466,17 +478,17 @@ const ApiChatRoute = ApiChatRouteImport.update({
path: '/api/chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiByokChatRoute = ApiByokChatRouteImport.update({
id: '/api/byok-chat',
path: '/api/byok-chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiByteplusSeedance1080pWireRoute =
ApiByteplusSeedance1080pWireRouteImport.update({
id: '/api/byteplus-seedance-1080p-wire',
path: '/api/byteplus-seedance-1080p-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiByokChatRoute = ApiByokChatRouteImport.update({
id: '/api/byok-chat',
path: '/api/byok-chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAudioRoute = ApiAudioRouteImport.update({
id: '/api/audio',
path: '/api/audio',
Expand Down Expand Up @@ -553,6 +565,7 @@ export interface FileRoutesByFullPath {
'/markdown-cjk': typeof MarkdownCjkRoute
'/middleware-test': typeof MiddlewareTestRoute
'/persistence-durability': typeof PersistenceDurabilityRoute
'/tool-first-text': typeof ToolFirstTextRoute
'/tools-test': typeof ToolsTestRoute
'/websocket-adapter': typeof WebsocketAdapterRoute
'/$provider/$feature': typeof ProviderFeatureRoute
Expand Down Expand Up @@ -609,6 +622,7 @@ export interface FileRoutesByFullPath {
'/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -639,6 +653,7 @@ export interface FileRoutesByTo {
'/markdown-cjk': typeof MarkdownCjkRoute
'/middleware-test': typeof MiddlewareTestRoute
'/persistence-durability': typeof PersistenceDurabilityRoute
'/tool-first-text': typeof ToolFirstTextRoute
'/tools-test': typeof ToolsTestRoute
'/websocket-adapter': typeof WebsocketAdapterRoute
'/$provider/$feature': typeof ProviderFeatureRoute
Expand Down Expand Up @@ -695,6 +710,7 @@ export interface FileRoutesByTo {
'/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -726,6 +742,7 @@ export interface FileRoutesById {
'/markdown-cjk': typeof MarkdownCjkRoute
'/middleware-test': typeof MiddlewareTestRoute
'/persistence-durability': typeof PersistenceDurabilityRoute
'/tool-first-text': typeof ToolFirstTextRoute
'/tools-test': typeof ToolsTestRoute
'/websocket-adapter': typeof WebsocketAdapterRoute
'/$provider/$feature': typeof ProviderFeatureRoute
Expand Down Expand Up @@ -782,6 +799,7 @@ export interface FileRoutesById {
'/api/sandbox-tool-history': typeof ApiSandboxToolHistoryRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tool-first-text-wire': typeof ApiToolFirstTextWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -814,6 +832,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
| '/$provider/$feature'
Expand Down Expand Up @@ -870,6 +889,7 @@ export interface FileRouteTypes {
| '/api/sandbox-tool-history'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tool-first-text-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -900,6 +920,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
| '/$provider/$feature'
Expand Down Expand Up @@ -956,6 +977,7 @@ export interface FileRouteTypes {
| '/api/sandbox-tool-history'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tool-first-text-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -986,6 +1008,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
| '/$provider/$feature'
Expand Down Expand Up @@ -1042,6 +1065,7 @@ export interface FileRouteTypes {
| '/api/sandbox-tool-history'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tool-first-text-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -1073,6 +1097,7 @@ export interface RootRouteChildren {
MarkdownCjkRoute: typeof MarkdownCjkRoute
MiddlewareTestRoute: typeof MiddlewareTestRoute
PersistenceDurabilityRoute: typeof PersistenceDurabilityRoute
ToolFirstTextRoute: typeof ToolFirstTextRoute
ToolsTestRoute: typeof ToolsTestRoute
WebsocketAdapterRoute: typeof WebsocketAdapterRoute
ProviderFeatureRoute: typeof ProviderFeatureRoute
Expand Down Expand Up @@ -1129,6 +1154,7 @@ export interface RootRouteChildren {
ApiSandboxToolHistoryRoute: typeof ApiSandboxToolHistoryRoute
ApiSummarizeRoute: typeof ApiSummarizeRoute
ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute
ApiToolFirstTextWireRoute: typeof ApiToolFirstTextWireRoute
ApiToolsTestRoute: typeof ApiToolsTestRoute
ApiTranscriptionRoute: typeof ApiTranscriptionRouteWithChildren
ApiTtsRoute: typeof ApiTtsRouteWithChildren
Expand All @@ -1152,6 +1178,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ToolsTestRouteImport
parentRoute: typeof rootRouteImport
}
'/tool-first-text': {
id: '/tool-first-text'
path: '/tool-first-text'
fullPath: '/tool-first-text'
preLoaderRoute: typeof ToolFirstTextRouteImport
parentRoute: typeof rootRouteImport
}
'/persistence-durability': {
id: '/persistence-durability'
path: '/persistence-durability'
Expand Down Expand Up @@ -1313,6 +1346,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiToolsTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/tool-first-text-wire': {
id: '/api/tool-first-text-wire'
path: '/api/tool-first-text-wire'
fullPath: '/api/tool-first-text-wire'
preLoaderRoute: typeof ApiToolFirstTextWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/tool-call-lifecycle-wire': {
id: '/api/tool-call-lifecycle-wire'
path: '/api/tool-call-lifecycle-wire'
Expand Down Expand Up @@ -1635,20 +1675,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/byok-chat': {
id: '/api/byok-chat'
path: '/api/byok-chat'
fullPath: '/api/byok-chat'
preLoaderRoute: typeof ApiByokChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/byteplus-seedance-1080p-wire': {
id: '/api/byteplus-seedance-1080p-wire'
path: '/api/byteplus-seedance-1080p-wire'
fullPath: '/api/byteplus-seedance-1080p-wire'
preLoaderRoute: typeof ApiByteplusSeedance1080pWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/byok-chat': {
id: '/api/byok-chat'
path: '/api/byok-chat'
fullPath: '/api/byok-chat'
preLoaderRoute: typeof ApiByokChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/audio': {
id: '/api/audio'
path: '/api/audio'
Expand Down Expand Up @@ -1806,6 +1846,7 @@ const rootRouteChildren: RootRouteChildren = {
MarkdownCjkRoute: MarkdownCjkRoute,
MiddlewareTestRoute: MiddlewareTestRoute,
PersistenceDurabilityRoute: PersistenceDurabilityRoute,
ToolFirstTextRoute: ToolFirstTextRoute,
ToolsTestRoute: ToolsTestRoute,
WebsocketAdapterRoute: WebsocketAdapterRoute,
ProviderFeatureRoute: ProviderFeatureRoute,
Expand Down Expand Up @@ -1862,6 +1903,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSandboxToolHistoryRoute: ApiSandboxToolHistoryRoute,
ApiSummarizeRoute: ApiSummarizeRoute,
ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute,
ApiToolFirstTextWireRoute: ApiToolFirstTextWireRoute,
ApiToolsTestRoute: ApiToolsTestRoute,
ApiTranscriptionRoute: ApiTranscriptionRouteWithChildren,
ApiTtsRoute: ApiTtsRouteWithChildren,
Expand Down
Loading