diff --git a/.changeset/gentle-horses-sniff.md b/.changeset/gentle-horses-sniff.md new file mode 100644 index 000000000000..3a8b43a6871c --- /dev/null +++ b/.changeset/gentle-horses-sniff.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/langchain': patch +--- + +fix(langchain): ensure message id consistency diff --git a/examples/next-langchain/app/api/reasoning/route.ts b/examples/next-langchain/app/api/reasoning/route.ts new file mode 100644 index 000000000000..b75dfa95613d --- /dev/null +++ b/examples/next-langchain/app/api/reasoning/route.ts @@ -0,0 +1,32 @@ +import { toBaseMessages, toUIMessageStream } from '@ai-sdk/langchain'; +import { ChatOpenAI } from '@langchain/openai'; +import { createUIMessageStreamResponse, UIMessage } from 'ai'; +import { NextResponse } from 'next/server'; + +export const maxDuration = 30; + +/** + * this configuration streams reasoning summaries before the final response (thus triggering the error) + */ +const model = new ChatOpenAI({ + model: 'gpt-5', + useResponsesApi: true, + reasoning: { effort: 'medium', summary: 'concise' }, +}); + +export async function POST(req: Request) { + try { + const { messages }: { messages: UIMessage[] } = await req.json(); + + const langchainMessages = await toBaseMessages(messages); + const stream = await model.stream(langchainMessages as never); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream(stream), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'An unknown error occurred'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/examples/next-langchain/app/reasoning/page.tsx b/examples/next-langchain/app/reasoning/page.tsx new file mode 100644 index 000000000000..0a31ea0b5c8d --- /dev/null +++ b/examples/next-langchain/app/reasoning/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { useMemo } from 'react'; +import { ChatContainer } from '../../components/chat-container'; +import { type CustomDataMessage } from '../types'; + +export default function ReasoningChat() { + const transport = useMemo( + () => new DefaultChatTransport({ api: '/api/reasoning' }), + [], + ); + + const { messages, sendMessage, status, error } = useChat({ + transport, + }); + + return ( + + Uses ChatOpenAI with OpenAI Responses API and reasoning + enabled. This streams reasoning summaries before the final response, + demonstrating the @ai-sdk/langchain adapter's + support for reasoning content. + + } + messages={messages} + onSend={text => sendMessage({ text })} + status={status} + error={error} + placeholder="Ask a question that requires reasoning..." + suggestions={[ + 'How many rs are in strawberry?', + 'What is 15% of 80?', + 'If I have 3 apples and give away half, how many do I have?', + ]} + /> + ); +} diff --git a/packages/langchain/src/utils.test.ts b/packages/langchain/src/utils.test.ts index 534117d36eae..e92427d42e8b 100644 --- a/packages/langchain/src/utils.test.ts +++ b/packages/langchain/src/utils.test.ts @@ -652,6 +652,102 @@ describe('processModelChunk', () => { expect(state.reasoningStarted).toBe(false); expect(state.textStarted).toBe(true); }); + + it('should maintain consistent IDs when chunk.id changes during reasoning-to-text transition', () => { + // This test reproduces the bug where the message ID changes between chunks, + // which would cause the client to fail to look up activeReasoningParts or activeTextParts + // because the ID used for *-start doesn't match the ID used for *-delta and *-end. + // + const state = { + started: false, + messageId: 'default', + reasoningStarted: false, + textStarted: false, + reasoningMessageId: null as string | null, + textMessageId: null as string | null, + }; + const chunks: unknown[] = []; + const controller = createMockController(chunks); + + // First reasoning chunk arrives with id "run-abc123" + const reasoningChunk1 = new AIMessageChunk({ + content: '', + id: 'run-abc123', + }); + Object.defineProperty(reasoningChunk1, 'contentBlocks', { + get: () => [{ type: 'reasoning', reasoning: 'Let me think...' }], + }); + processModelChunk(reasoningChunk1, state, controller); + + const reasoningChunk2 = new AIMessageChunk({ + content: '', + id: 'msg-xyz789', + }); + Object.defineProperty(reasoningChunk2, 'contentBlocks', { + get: () => [{ type: 'reasoning', reasoning: ' about this.' }], + }); + processModelChunk(reasoningChunk2, state, controller); + + // Text chunk arrives with the new id "msg-xyz789" + const textChunk = new AIMessageChunk({ + content: 'Here is my answer.', + id: 'msg-xyz789', + }); + processModelChunk(textChunk, state, controller); + + // Verify all reasoning chunks use the same id that was used for reasoning-start + // and all text chunks use the same id that was used for text-start + expect(chunks).toEqual([ + { type: 'reasoning-start', id: 'run-abc123' }, + { type: 'reasoning-delta', delta: 'Let me think...', id: 'run-abc123' }, + { type: 'reasoning-delta', delta: ' about this.', id: 'run-abc123' }, + { type: 'reasoning-end', id: 'run-abc123' }, + { type: 'text-start', id: 'msg-xyz789' }, + { type: 'text-delta', delta: 'Here is my answer.', id: 'msg-xyz789' }, + ]); + + expect(state.reasoningMessageId).toBe('run-abc123'); + expect(state.textMessageId).toBe('msg-xyz789'); + expect(state.messageId).toBe('msg-xyz789'); + }); + + it('should maintain consistent text IDs when chunk.id changes during text streaming', () => { + // Similar bug can occur with text-only streaming if the ID changes between chunks + + const state = { + started: false, + messageId: 'default', + reasoningStarted: false, + textStarted: false, + reasoningMessageId: null as string | null, + textMessageId: null as string | null, + }; + const chunks: unknown[] = []; + const controller = createMockController(chunks); + + // First text chunk arrives with id "run-abc123" + const textChunk1 = new AIMessageChunk({ + content: 'Hello', + id: 'run-abc123', + }); + processModelChunk(textChunk1, state, controller); + + const textChunk2 = new AIMessageChunk({ + content: ' world!', + id: 'msg-xyz789', + }); + processModelChunk(textChunk2, state, controller); + + // Verify all text chunks use the same id that was used for text-start + expect(chunks).toEqual([ + { type: 'text-start', id: 'run-abc123' }, + { type: 'text-delta', delta: 'Hello', id: 'run-abc123' }, + { type: 'text-delta', delta: ' world!', id: 'run-abc123' }, + ]); + + expect(state.textMessageId).toBe('run-abc123'); + expect(state.messageId).toBe('msg-xyz789'); + }); }); describe('isPlainMessageObject', () => { diff --git a/packages/langchain/src/utils.ts b/packages/langchain/src/utils.ts index d1c41e9895f9..29d1011870eb 100644 --- a/packages/langchain/src/utils.ts +++ b/packages/langchain/src/utils.ts @@ -375,6 +375,10 @@ export function processModelChunk( messageId: string; reasoningStarted?: boolean; textStarted?: boolean; + /** Track the ID used for reasoning-start to ensure reasoning-end uses the same ID */ + reasoningMessageId?: string | null; + /** Track the ID used for text-start to ensure text-end uses the same ID */ + textMessageId?: string | null; emittedImages?: Set; }, controller: ReadableStreamDefaultController, @@ -432,6 +436,8 @@ export function processModelChunk( extractReasoningFromValuesMessage(chunk); if (reasoning) { if (!state.reasoningStarted) { + // Track the ID used for reasoning-start to ensure subsequent chunks use the same ID + state.reasoningMessageId = state.messageId; controller.enqueue({ type: 'reasoning-start', id: state.messageId }); state.reasoningStarted = true; state.started = true; @@ -439,7 +445,7 @@ export function processModelChunk( controller.enqueue({ type: 'reasoning-delta', delta: reasoning, - id: state.messageId, + id: state.reasoningMessageId ?? state.messageId, }); } @@ -467,11 +473,16 @@ export function processModelChunk( * If reasoning was streamed before text, close reasoning first */ if (state.reasoningStarted && !state.textStarted) { - controller.enqueue({ type: 'reasoning-end', id: state.messageId }); + controller.enqueue({ + type: 'reasoning-end', + id: state.reasoningMessageId ?? state.messageId, + }); state.reasoningStarted = false; } if (!state.textStarted) { + // Track the ID used for text-start to ensure subsequent chunks use the same ID + state.textMessageId = state.messageId; controller.enqueue({ type: 'text-start', id: state.messageId }); state.textStarted = true; state.started = true; @@ -479,7 +490,7 @@ export function processModelChunk( controller.enqueue({ type: 'text-delta', delta: text, - id: state.messageId, + id: state.textMessageId ?? state.messageId, }); } }