Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/text-first-tool-drop.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/ai/src/activities/chat/stream/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
42 changes: 42 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -850,6 +868,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/text-first-tool'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -940,6 +960,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/text-first-tool'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1030,6 +1052,7 @@ export interface FileRouteTypes {
| '/markdown-cjk'
| '/middleware-test'
| '/persistence-durability'
| '/text-first-tool'
| '/tool-first-text'
| '/tools-test'
| '/websocket-adapter'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1886,6 +1926,7 @@ const rootRouteChildren: RootRouteChildren = {
MarkdownCjkRoute: MarkdownCjkRoute,
MiddlewareTestRoute: MiddlewareTestRoute,
PersistenceDurabilityRoute: PersistenceDurabilityRoute,
TextFirstToolRoute: TextFirstToolRoute,
ToolFirstTextRoute: ToolFirstTextRoute,
ToolsTestRoute: ToolsTestRoute,
WebsocketAdapterRoute: WebsocketAdapterRoute,
Expand Down Expand Up @@ -1944,6 +1985,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSandboxFilePersistenceRoute: ApiSandboxFilePersistenceRoute,
ApiSandboxToolHistoryRoute: ApiSandboxToolHistoryRoute,
ApiSummarizeRoute: ApiSummarizeRoute,
ApiTextFirstToolWireRoute: ApiTextFirstToolWireRoute,
ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute,
ApiToolFirstTextWireRoute: ApiToolFirstTextWireRoute,
ApiToolsTestRoute: ApiToolsTestRoute,
Expand Down
87 changes: 87 additions & 0 deletions testing/e2e/src/routes/api.text-first-tool-wire.ts
Original file line number Diff line number Diff line change
@@ -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<StreamChunk> {
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<StreamChunk>
}

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<string, unknown>).threadId)
: 'thread-1'
const runId = `run-${threadId}`
return toServerSentEventsResponse(textFirstRun(threadId, runId))
},
},
},
})
41 changes: 41 additions & 0 deletions testing/e2e/src/routes/text-first-tool.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="text-first-tool-page">
<div data-testid="assistant-text">{assistantText}</div>
</div>
)
}

export const Route = createFileRoute('/text-first-tool')({
component: TextFirstToolPage,
})
16 changes: 16 additions & 0 deletions testing/e2e/tests/text-first-tool.spec.ts
Original file line number Diff line number Diff line change
@@ -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.')
})
})
Loading