diff --git a/.changeset/tool-first-parent-message-id.md b/.changeset/tool-first-parent-message-id.md new file mode 100644 index 000000000..8569432d5 --- /dev/null +++ b/.changeset/tool-first-parent-message-id.md @@ -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). diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 1d45b25f8..bdffea41c 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -999,6 +999,7 @@ export class AnthropicTextAdapter< toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, + parentMessageId: messageId, model, timestamp: Date.now(), index: currentToolIndex, @@ -1053,6 +1054,7 @@ export class AnthropicTextAdapter< toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, + parentMessageId: messageId, model, timestamp: Date.now(), index: currentToolIndex, diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index c4ef4fa32..611c38a33 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -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 => + chunk.type === 'TEXT_MESSAGE_START', + ) + const toolStart = chunks.find( + (chunk): chunk is Extract => + chunk.type === 'TOOL_CALL_START', + ) + + expect(textStart).toBeDefined() + expect(toolStart).toBeDefined() + expect(toolStart?.parentMessageId).toBe(textStart?.messageId) + }) }) describe('Anthropic adapter error handling', () => { diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index b822deaa2..d76755fd6 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -446,6 +446,7 @@ export class GeminiTextAdapter< toolCallId, toolCallName: toolCallData.name, toolName: toolCallData.name, + parentMessageId: messageId, model, timestamp: Date.now(), index: toolCallData.index, @@ -524,6 +525,7 @@ export class GeminiTextAdapter< toolCallId, toolCallName: functionCall.name || '', toolName: functionCall.name || '', + parentMessageId: messageId, model, timestamp: Date.now(), index: nextToolIndex - 1, diff --git a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts index b27feaa1a..2ce2e666a 100644 --- a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts +++ b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts @@ -1064,6 +1064,7 @@ async function* translateInteractionEvents( toolCallId, toolCallName: state.name, toolName: state.name, + parentMessageId: messageId, model, timestamp, index: state.index, diff --git a/packages/ai-gemini/tests/gemini-adapter.test.ts b/packages/ai-gemini/tests/gemini-adapter.test.ts index 63ac9d6cc..31d7c3246 100644 --- a/packages/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/ai-gemini/tests/gemini-adapter.test.ts @@ -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 = [ { diff --git a/packages/ai-gemini/tests/text-interactions-adapter.test.ts b/packages/ai-gemini/tests/text-interactions-adapter.test.ts index 1bc235610..547a9e461 100644 --- a/packages/ai-gemini/tests/text-interactions-adapter.test.ts +++ b/packages/ai-gemini/tests/text-interactions-adapter.test.ts @@ -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([ diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index dcf9b126d..d082db0af 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -226,6 +226,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< toolCallId, toolCallName: actualToolCall.function.name || '', toolName: actualToolCall.function.name || '', + parentMessageId: messageId, model: chunk.model, timestamp: Date.now(), index: actualToolCall.function.index, diff --git a/packages/ai-ollama/tests/text-adapter.test.ts b/packages/ai-ollama/tests/text-adapter.test.ts index 3efc80753..f5836bd70 100644 --- a/packages/ai-ollama/tests/text-adapter.test.ts +++ b/packages/ai-ollama/tests/text-adapter.test.ts @@ -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([ diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index a658d985e..fe51786b4 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -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, @@ -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, @@ -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, diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 2648da0a3..9d0de48b1 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -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, diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 83d8c9251..fd38c9ae7 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -382,6 +382,73 @@ describe('OpenRouter adapter option mapping', () => { } }) + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { + // Tool call streams 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). + const streamChunks = [ + { + id: 'chatcmpl-tf', + model: 'openai/gpt-4o-mini', + choices: [ + { + delta: { + toolCalls: [ + { + index: 0, + id: 'call_tf', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + finishReason: null, + }, + ], + }, + { + id: 'chatcmpl-tf', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'It is sunny.' }, finishReason: null }], + }, + { + id: 'chatcmpl-tf', + model: 'openai/gpt-4o-mini', + choices: [{ delta: {}, finishReason: 'stop' }], + usage: { promptTokens: 10, completionTokens: 7, totalTokens: 17 }, + }, + ] + + setupMockSdkClient(streamChunks) + + const adapter = createAdapter() + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'What is the weather in Berlin?' }], + tools: [weatherTool], + logger: testLogger, + })) { + chunks.push(chunk) + } + + 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('handles multimodal input with text and image', async () => { const streamChunks = [ { diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index a9bcfaa21..efc79969f 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -659,6 +659,84 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finished.finishReason).toBe('tool_calls') }) + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { + // The function call streams 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). + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_tf', + callId: 'call_tf', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.delta', + sequenceNumber: 2, + itemId: 'item_tf', + outputIndex: 0, + delta: '{"location":"Berlin"}', + }, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 3, + itemId: 'item_tf', + outputIndex: 0, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.output_text.delta', + sequenceNumber: 4, + itemId: 'msg_tf', + outputIndex: 1, + contentIndex: 0, + delta: 'It is sunny.', + }, + { + type: 'response.completed', + sequenceNumber: 5, + response: { + model: 'm', + output: [{ type: 'function_call' }], + usage: { inputTokens: 1, outputTokens: 7, totalTokens: 8 }, + }, + }, + ]) + + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of chat({ + adapter, + messages: [{ role: 'user', content: 'What is the weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(c) + } + + 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('surfaces response.failed with a RUN_ERROR carrying the error message + code', async () => { setupMockSdkClient([ { diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index c690fc604..d69e98649 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -2676,6 +2676,53 @@ describe('StreamProcessor', () => { expect(toolCallPart.state).toBe('input-complete') } }) + + it('should preserve the server message id in tool-first flows when parentMessageId is provided', () => { + const processor = new StreamProcessor() + + processor.processChunk( + chunk(EventType.TOOL_CALL_START, { + toolCallId: 'tc-1', + toolCallName: 'lookupWeather', + toolName: 'lookupWeather', + parentMessageId: 'anthropic-msg-1', + }), + ) + + let messages = processor.getMessages() + expect(messages).toHaveLength(1) + expect(messages[0]?.id).toBe('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, + }), + ) + processor.processChunk(ev.textContent('It is sunny.', 'anthropic-msg-1')) + processor.processChunk(ev.textEnd('anthropic-msg-1')) + processor.finalizeStream() + + messages = processor.getMessages() + expect(messages).toHaveLength(1) + expect(messages[0]?.id).toBe('anthropic-msg-1') + expect(messages[0]?.parts).toEqual([ + { + type: 'tool-call', + id: 'tc-1', + name: 'lookupWeather', + arguments: '{"location":"Berlin"}', + state: 'input-complete', + }, + { + type: 'text', + content: 'It is sunny.', + }, + ]) + }) }) describe('double onStreamEnd guard', () => { diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 488c31836..ed611c767 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -838,6 +838,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, + parentMessageId: aguiState.messageId, model: chunk.model || options.model, timestamp: Date.now(), index, diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 454c96e03..5f1dd5e01 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1199,6 +1199,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< toolCallId: item.id, toolCallName: metadata.name, toolName: metadata.name, + parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: chunk.output_index, @@ -1340,6 +1341,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< toolCallId: item.id, toolCallName: metadata.name, toolName: metadata.name, + parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, @@ -1418,6 +1420,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< toolCallId: item.id, toolCallName: metadata.name, toolName: metadata.name, + parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, diff --git a/packages/openai-base/tests/chat-completions-text.test.ts b/packages/openai-base/tests/chat-completions-text.test.ts index 656dd876b..b3bad4069 100644 --- a/packages/openai-base/tests/chat-completions-text.test.ts +++ b/packages/openai-base/tests/chat-completions-text.test.ts @@ -549,6 +549,74 @@ describe('OpenAIBaseChatCompletionsTextAdapter', () => { expect(runFinishedChunk.finishReason).toBe('tool_calls') } }) + + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { + // Tool call streams 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). + const streamChunks = [ + { + id: 'chatcmpl-tf', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_tf', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-tf', + model: 'test-model', + choices: [ + { delta: { content: 'It is sunny.' }, finish_reason: null }, + ], + }, + { + id: 'chatcmpl-tf', + model: 'test-model', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 7, total_tokens: 17 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + 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) + } + }) }) describe('error handling', () => { diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 72b198afa..70b1fc07d 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -773,6 +773,84 @@ describe('OpenAIBaseResponsesTextAdapter', () => { } }) + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { + // Tool call arrives before any text. parentMessageId must bind the tool + // call to the same assistant message id the eventual TEXT_MESSAGE_START + // uses, so consumers don't see the message id change mid-stream (#477). + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-tf', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'call_tf', + name: 'lookup_weather', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call_tf', + delta: '{"location":"Berlin"}', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call_tf', + arguments: '{"location":"Berlin"}', + }, + { type: 'response.output_text.delta', delta: 'It is sunny.' }, + { + type: 'response.completed', + response: { + id: 'resp-tf', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call_tf', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { input_tokens: 10, output_tokens: 7, total_tokens: 17 }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + 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('handles multiple parallel tool calls', async () => { const streamChunks = [ {