From dc23cbf1ea55f8aaac4e13750c6a9a8b6cdd1c7b Mon Sep 17 00:00:00 2001 From: Shaked Hagag Date: Mon, 20 Apr 2026 18:56:15 +0300 Subject: [PATCH] fix: preserve anthropic assistant ids in tool-first streams --- .../ai-anthropic/src/adapters/text.ts | 2 + .../tests/anthropic-adapter.test.ts | 67 +++++++++++++++++++ .../ai/tests/stream-processor.test.ts | 47 +++++++++++++ 3 files changed, 116 insertions(+) diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index c88c1159f..7a931e2d9 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -686,6 +686,7 @@ export class AnthropicTextAdapter< toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, + parentMessageId: messageId, model, timestamp, index: currentToolIndex, @@ -716,6 +717,7 @@ export class AnthropicTextAdapter< toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, + parentMessageId: messageId, model, timestamp, index: currentToolIndex, diff --git a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts index a1bcd593b..bd8c2777c 100644 --- a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts @@ -686,4 +686,71 @@ 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-20250219') + + 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) + }) }) diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 02145e9e3..454013eb7 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -2485,6 +2485,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('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('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', () => {