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
26 changes: 26 additions & 0 deletions .changeset/tool-first-parent-message-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@tanstack/ai-anthropic': patch
'@tanstack/openai-base': patch
'@tanstack/ai-openrouter': patch
'@tanstack/ai-gemini': patch
'@tanstack/ai-ollama': patch
---

Bind tool calls to the assistant message in tool-first streams by setting AG-UI's
`parentMessageId` on `TOOL_CALL_START`.

When a provider streams a tool call **before** any text, the `StreamProcessor` had no
active assistant message to attach it to, so it created one under a temporary local id.
The later `TEXT_MESSAGE_START` then carried the real provider message id, forcing a
mid-stream id change — which destabilizes `UIMessage.id` and can remount the message
subtree in `useChat` (React list keys, etc.). See #477.

Every text adapter generates one stable assistant message id per stream and already uses
it for `TEXT_MESSAGE_START`; they now also emit it as `parentMessageId` on
`TOOL_CALL_START`. The processor reads `chunk.parentMessageId` (`?? active assistant id`)
so the message is created with the correct id immediately and the subsequent
`TEXT_MESSAGE_START` matches — no rename, no remount.

Fixed across all adapters that emit `TOOL_CALL_START` (Anthropic, OpenAI Responses +
Chat Completions via `@tanstack/openai-base`, OpenRouter, Gemini including the
experimental text-interactions adapter, and Ollama).
2 changes: 2 additions & 0 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ export class AnthropicTextAdapter<
toolCallId: existing.id,
toolCallName: existing.name,
toolName: existing.name,
parentMessageId: messageId,
model,
timestamp: Date.now(),
index: currentToolIndex,
Expand Down Expand Up @@ -1053,6 +1054,7 @@ export class AnthropicTextAdapter<
toolCallId: existing.id,
toolCallName: existing.name,
toolName: existing.name,
parentMessageId: messageId,
model,
timestamp: Date.now(),
index: currentToolIndex,
Expand Down
67 changes: 67 additions & 0 deletions packages/ai-anthropic/tests/anthropic-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,73 @@ describe('Anthropic stream processing', () => {
type: 'RUN_FINISHED',
})
})

it('emits parentMessageId on tool-first tool call chunks', async () => {
const mockStream = (async function* () {
yield {
type: 'content_block_start',
index: 0,
content_block: {
type: 'tool_use',
id: 'toolu_weather',
name: 'lookup_weather',
input: {},
},
}
yield {
type: 'content_block_delta',
index: 0,
delta: {
type: 'input_json_delta',
partial_json: '{"location":"Berlin"}',
},
}
yield { type: 'content_block_stop', index: 0 }
yield {
type: 'content_block_start',
index: 1,
content_block: { type: 'text', text: '' },
}
yield {
type: 'content_block_delta',
index: 1,
delta: { type: 'text_delta', text: 'It is sunny.' },
}
yield { type: 'content_block_stop', index: 1 }
yield {
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 7 },
}
yield { type: 'message_stop' }
})()

mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)

const adapter = createAdapter('claude-3-7-sonnet')

const chunks: StreamChunk[] = []
for await (const chunk of chat({
adapter,
messages: [{ role: 'user', content: 'What is the weather in Berlin?' }],
tools: [weatherTool],
})) {
chunks.push(chunk)
}

const textStart = chunks.find(
(chunk): chunk is Extract<StreamChunk, { type: 'TEXT_MESSAGE_START' }> =>
chunk.type === 'TEXT_MESSAGE_START',
)
const toolStart = chunks.find(
(chunk): chunk is Extract<StreamChunk, { type: 'TOOL_CALL_START' }> =>
chunk.type === 'TOOL_CALL_START',
)

expect(textStart).toBeDefined()
expect(toolStart).toBeDefined()
expect(toolStart?.parentMessageId).toBe(textStart?.messageId)
})
})

describe('Anthropic adapter error handling', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ export class GeminiTextAdapter<
toolCallId,
toolCallName: toolCallData.name,
toolName: toolCallData.name,
parentMessageId: messageId,
model,
timestamp: Date.now(),
index: toolCallData.index,
Expand Down Expand Up @@ -524,6 +525,7 @@ export class GeminiTextAdapter<
toolCallId,
toolCallName: functionCall.name || '',
toolName: functionCall.name || '',
parentMessageId: messageId,
model,
timestamp: Date.now(),
index: nextToolIndex - 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,7 @@ async function* translateInteractionEvents(
toolCallId,
toolCallName: state.name,
toolName: state.name,
parentMessageId: messageId,
model,
timestamp,
index: state.index,
Expand Down
61 changes: 61 additions & 0 deletions packages/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,67 @@ describe('GeminiAdapter through AI', () => {
})
})

it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => {
// A functionCall part arrives before any text. parentMessageId must bind the
// tool call to the same assistant message id the eventual TEXT_MESSAGE_START
// uses so the message id stays stable mid-stream (#477).
const streamChunks = [
{
candidates: [
{
content: {
parts: [
{
functionCall: {
name: 'lookup_weather',
args: { location: 'Berlin' },
},
},
],
},
},
],
},
{
candidates: [
{
content: { parts: [{ text: 'It is sunny.' }] },
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 4,
candidatesTokenCount: 7,
totalTokenCount: 11,
},
},
]

mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks))

const adapter = createTextAdapter()
const received: StreamChunk[] = []
for await (const chunk of chat({
adapter,
messages: [{ role: 'user', content: 'What is the weather in Berlin?' }],
tools: [weatherTool],
})) {
received.push(chunk)
}

const textStart = received.find((c) => c.type === 'TEXT_MESSAGE_START')
const toolStart = received.find((c) => c.type === 'TOOL_CALL_START')

expect(textStart?.type).toBe('TEXT_MESSAGE_START')
expect(toolStart?.type).toBe('TOOL_CALL_START')
if (
textStart?.type === 'TEXT_MESSAGE_START' &&
toolStart?.type === 'TOOL_CALL_START'
) {
expect(toolStart.parentMessageId).toBe(textStart.messageId)
}
})

it('merges consecutive user messages when tool results precede a follow-up user message', async () => {
const streamChunks = [
{
Expand Down
71 changes: 71 additions & 0 deletions packages/ai-gemini/tests/text-interactions-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,77 @@ describe('GeminiTextInteractionsAdapter', () => {
expect(finished.finishReason).toBe('tool_calls')
})

it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => {
// The function_call content arrives before any text. parentMessageId must
// bind the tool call to the same assistant message id the eventual
// TEXT_MESSAGE_START uses so the message id stays stable mid-stream (#477).
mocks.interactionsCreateSpy.mockResolvedValue(
mkStream([
{
event_type: 'interaction.start',
interaction: { id: 'int_tf', status: 'in_progress' },
},
{
event_type: 'content.start',
index: 0,
content: { type: 'function_call' },
},
{
event_type: 'content.delta',
index: 0,
delta: {
type: 'function_call',
id: 'call_tf',
name: 'lookup_weather',
arguments: { location: 'Berlin' },
},
},
{ event_type: 'content.stop', index: 0 },
{
event_type: 'content.start',
index: 1,
content: { type: 'text', text: '' },
},
{
event_type: 'content.delta',
index: 1,
delta: { type: 'text', text: 'It is sunny.' },
},
{ event_type: 'content.stop', index: 1 },
{
event_type: 'interaction.complete',
interaction: { id: 'int_tf', status: 'completed' },
},
]),
)

const weatherTool: Tool = {
name: 'lookup_weather',
description: 'Return the weather for a location',
}

const adapter = createAdapter()
const chunks = await collectChunks(
chat({
adapter,
messages: [{ role: 'user', content: 'Weather in Berlin?' }],
tools: [weatherTool],
}),
)

const textStart = chunks.find((c) => c.type === 'TEXT_MESSAGE_START')
const toolStart = chunks.find((c) => c.type === 'TOOL_CALL_START')

expect(textStart?.type).toBe('TEXT_MESSAGE_START')
expect(toolStart?.type).toBe('TOOL_CALL_START')
if (
textStart?.type === 'TEXT_MESSAGE_START' &&
toolStart?.type === 'TOOL_CALL_START'
) {
expect(toolStart.parentMessageId).toBe(textStart.messageId)
}
})

it('serializes tool results as function_result content blocks', async () => {
mocks.interactionsCreateSpy.mockResolvedValue(
mkStream([
Expand Down
1 change: 1 addition & 0 deletions packages/ai-ollama/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export class OllamaTextAdapter<TModel extends string> extends BaseTextAdapter<
toolCallId,
toolCallName: actualToolCall.function.name || '',
toolName: actualToolCall.function.name || '',
parentMessageId: messageId,
model: chunk.model,
timestamp: Date.now(),
index: actualToolCall.function.index,
Expand Down
54 changes: 54 additions & 0 deletions packages/ai-ollama/tests/text-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,60 @@ describe('OllamaTextAdapter.chatStream (tool calls)', () => {
expect(startChunk!.toolCallId).toBe('tc-123')
})

it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => {
// The tool call arrives before any text content. parentMessageId must bind
// the tool call to the same assistant message id the eventual
// TEXT_MESSAGE_START uses so the message id stays stable mid-stream (#477).
chatMock.mockResolvedValueOnce(
asyncIterable([
{
message: {
role: 'assistant',
content: '',
tool_calls: [
{
id: 'tc-tf',
function: { name: 'search', arguments: { q: 'cats' } },
},
],
},
done: false,
},
{
message: { role: 'assistant', content: 'Found some cats.' },
done: false,
},
{
message: { role: 'assistant', content: '' },
done: true,
done_reason: 'stop',
},
]),
)

const adapter = createOllamaChat('llama3.2')
const chunks = await collectStream(
adapter.chatStream({
logger: testLogger,
model: 'llama3.2',
messages: [{ role: 'user', content: 'find cats' }],
tools: [searchTool],
}),
)

const textStart = chunks.find((c) => c.type === 'TEXT_MESSAGE_START')
const toolStart = chunks.find((c) => c.type === 'TOOL_CALL_START')

expect(textStart?.type).toBe('TEXT_MESSAGE_START')
expect(toolStart?.type).toBe('TOOL_CALL_START')
if (
textStart?.type === 'TEXT_MESSAGE_START' &&
toolStart?.type === 'TOOL_CALL_START'
) {
expect(toolStart.parentMessageId).toBe(textStart.messageId)
}
})

it('synthesises a tool-call id when Ollama omits one', async () => {
chatMock.mockResolvedValueOnce(
asyncIterable([
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,7 @@ export class OpenRouterResponsesTextAdapter<
toolCallId: item.id,
toolCallName: metadata.name,
toolName: metadata.name,
parentMessageId: aguiState.messageId,
model: model || options.model,
timestamp: Date.now(),
index: chunk.outputIndex ?? 0,
Expand Down Expand Up @@ -1288,6 +1289,7 @@ export class OpenRouterResponsesTextAdapter<
toolCallId: item.id,
toolCallName: metadata.name,
toolName: metadata.name,
parentMessageId: aguiState.messageId,
model: model || options.model,
timestamp: Date.now(),
index: metadata.index,
Expand Down Expand Up @@ -1363,6 +1365,7 @@ export class OpenRouterResponsesTextAdapter<
toolCallId: item.id,
toolCallName: metadata.name,
toolName: metadata.name,
parentMessageId: aguiState.messageId,
model: model || options.model,
timestamp: Date.now(),
index: metadata.index,
Expand Down
1 change: 1 addition & 0 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export class OpenRouterTextAdapter<
toolCallId: toolCall.id,
toolCallName: toolCall.name,
toolName: toolCall.name,
parentMessageId: aguiState.messageId,
model: chunk.model || options.model,
timestamp: Date.now(),
index,
Expand Down
Loading