Checked other resources
Example Code
- Use
@langchain/openai with the Responses API and a reasoning model (e.g. gpt-5.2 with reasoning: { effort: "medium", summary: "auto" })
- Stream a response that includes both a reasoning summary and a text output
- Inspect the final accumulated
AIMessage.content array after all chunks are concatenated
Expected
[
{ "type": "reasoning", "reasoning": "The user wants...", "index": 0 },
{ "type": "text", "text": "Here are the results...", "index": 0 }
]
Actual
[
{ "type": "reasoning", "reasoning": "The user wants...", "text": "Here are the results...", "index": 0 }
]
The text response is trapped inside the reasoning block. Any downstream code that checks block.type === 'text' to extract the response will miss it entirely.
Error Message and Stack Trace (if applicable)
When the malformed message is persisted to a LangGraph checkpoint and then replayed as conversation history to OpenAI's Responses API, the API rejects it:
Error: 400 litellm.BadRequestError: AzureException BadRequestError - {
"error": {
"message": "Item 'rs_0f7cf321675f593e0069dd2d635e4c8197a81c09dbb591f65a' of type 'reasoning' was provided without its required following item.",
"type": "invalid_request_error",
"param": "input",
"code": null
}
}
No stack trace — this is an API-level 400 rejection, not a runtime exception.
Description
When streaming from OpenAI's Responses API with reasoning.summary: "auto" (or "detailed"), the final accumulated AIMessage can contain a single content block with type: "reasoning" that has both a reasoning field and a text field. The text field contains the model's actual response, which should have been a separate { type: "text" } block.
This happens because _findMergeTarget in @langchain/core matches content blocks by index value without checking type. When a reasoning summary delta arrives with index: 0 (from event.summary_index) and a text delta arrives with index: 0 (from event.content_index), they get merged into a single block.
Root cause
1. @langchain/openai assigns independent index namespaces to the same field
In libs/langchain-openai/src/converters/responses.ts (dist: dist/converters/responses.js), text deltas use event.content_index and reasoning deltas use event.summary_index:
Text delta (line ~451 of dist):
if (event.type === "response.output_text.delta") content.push({
type: "text",
text: event.delta,
index: event.content_index // from output_text event
});
Reasoning delta (line ~572 of dist):
if (event.delta) content.push({
type: "reasoning",
reasoning: event.delta,
index: event.summary_index // from reasoning_summary_text event
});
Both content_index and summary_index can be 0 — they come from different OpenAI output items (a reasoning item and a message item), each with their own internal indexing. But they're both assigned to the index field on the content block, making them look the same to the merge logic.
2. _findMergeTarget matches by index without checking type
In libs/langchain-core/src/messages/base.ts (dist: dist/messages/base.js), the merge target finder only checks index (and optionally id):
function _findMergeTarget(merged, item) {
const itemHasIndex = hasMergeableIndex(item);
const itemHasId = hasMergeableId(item);
if (!itemHasIndex && !itemHasId) return -1;
return merged.findIndex((leftItem) => {
const leftHasIndex = hasMergeableIndex(leftItem);
const leftHasId = hasMergeableId(leftItem);
if (itemHasIndex && leftHasIndex) {
if (!(leftItem.index === item.index)) return false;
// NO CHECK: leftItem.type === item.type
if (leftHasId && itemHasId) return leftItem.id === item.id;
return true;
}
// ...
});
}
When a { type: "text", index: 0 } block arrives and a { type: "reasoning", index: 0 } block already exists in merged, _findMergeTarget returns the reasoning block's position because index matches.
3. _mergeDicts preserves the left block's type and merges string fields
// In _mergeDicts:
else if (typeof merged[key] === "string")
if (key === "type") continue; // <-- keeps left type ("reasoning")
// ...
else merged[key] += value; // <-- appends text: "" + "Here are the results..."
The type field is explicitly skipped (preserves "reasoning" from the left block). The text field from the incoming text block gets string-concatenated onto the reasoning block (which initially had no text field, so it's set via the merged[key] == null path).
Result: { type: "reasoning", reasoning: "...", text: "...", index: 0 }.
Impact
Related issues
Suggested fix
_findMergeTarget should require type to match when both blocks have a type field:
if (itemHasIndex && leftHasIndex) {
if (leftItem.index !== item.index) return false;
// Don't merge blocks of different types even if index matches
if ('type' in leftItem && 'type' in item && leftItem.type !== item.type) return false;
if (leftHasId && itemHasId) return leftItem.id === item.id;
return true;
}
Alternatively, @langchain/openai could namespace the indices (e.g. reasoning:0 vs text:0) to prevent collisions without changing core merge logic.
Workaround
In the checkpoint-to-UIMessage conversion layer, check for the malformed block shape and extract both fields:
if (block.type === 'reasoning') {
if (block.reasoning) parts.push({ type: 'reasoning', text: block.reasoning });
if (block.text) parts.push({ type: 'text', text: block.text });
}
System Info
- Node.js: v22.x
- OS: macOS (darwin 25.3.0)
@langchain/core: 1.1.39
@langchain/openai: 1.4.4
langchain: 1.x (monorepo langchain wrapper)
- Model:
gpt-5.2 via OpenAI Responses API with reasoning: { effort: "medium", summary: "auto" }
Checked other resources
Example Code
@langchain/openaiwith the Responses API and a reasoning model (e.g.gpt-5.2withreasoning: { effort: "medium", summary: "auto" })AIMessage.contentarray after all chunks are concatenatedExpected
[ { "type": "reasoning", "reasoning": "The user wants...", "index": 0 }, { "type": "text", "text": "Here are the results...", "index": 0 } ]Actual
[ { "type": "reasoning", "reasoning": "The user wants...", "text": "Here are the results...", "index": 0 } ]The text response is trapped inside the reasoning block. Any downstream code that checks
block.type === 'text'to extract the response will miss it entirely.Error Message and Stack Trace (if applicable)
When the malformed message is persisted to a LangGraph checkpoint and then replayed as conversation history to OpenAI's Responses API, the API rejects it:
No stack trace — this is an API-level 400 rejection, not a runtime exception.
Description
When streaming from OpenAI's Responses API with
reasoning.summary: "auto"(or"detailed"), the final accumulatedAIMessagecan contain a single content block withtype: "reasoning"that has both areasoningfield and atextfield. Thetextfield contains the model's actual response, which should have been a separate{ type: "text" }block.This happens because
_findMergeTargetin@langchain/corematches content blocks byindexvalue without checkingtype. When a reasoning summary delta arrives withindex: 0(fromevent.summary_index) and a text delta arrives withindex: 0(fromevent.content_index), they get merged into a single block.Root cause
1.
@langchain/openaiassigns independent index namespaces to the same fieldIn
libs/langchain-openai/src/converters/responses.ts(dist:dist/converters/responses.js), text deltas useevent.content_indexand reasoning deltas useevent.summary_index:Text delta (line ~451 of dist):
Reasoning delta (line ~572 of dist):
Both
content_indexandsummary_indexcan be0— they come from different OpenAI output items (areasoningitem and amessageitem), each with their own internal indexing. But they're both assigned to theindexfield on the content block, making them look the same to the merge logic.2.
_findMergeTargetmatches byindexwithout checkingtypeIn
libs/langchain-core/src/messages/base.ts(dist:dist/messages/base.js), the merge target finder only checksindex(and optionallyid):When a
{ type: "text", index: 0 }block arrives and a{ type: "reasoning", index: 0 }block already exists inmerged,_findMergeTargetreturns the reasoning block's position becauseindexmatches.3.
_mergeDictspreserves the left block'stypeand merges string fieldsThe
typefield is explicitly skipped (preserves"reasoning"from the left block). Thetextfield from the incoming text block gets string-concatenated onto the reasoning block (which initially had notextfield, so it's set via themerged[key] == nullpath).Result:
{ type: "reasoning", reasoning: "...", text: "...", index: 0 }.Impact
typeindexto reasoning blocks to fix fragmentation — but introduced index collisions with text blocksRelated issues
indexin convertResponsesDeltaToChatGenerationChunk) #10684 — Reasoning blocks fragment during streaming (fixed by fix(openai): add index to streaming reasoning content blocks for proper chunk merging #10681, which introduced this regression)contentBlocksdropstype: "reasoning"blocks from OpenAI Responses API content array #10421 —contentBlocksdrops reasoning blocks entirely (still open)text. #9879 — Text blocks mapped toinput_textinstead ofoutput_textSuggested fix
_findMergeTargetshould requiretypeto match when both blocks have atypefield:Alternatively,
@langchain/openaicould namespace the indices (e.g.reasoning:0vstext:0) to prevent collisions without changing core merge logic.Workaround
In the checkpoint-to-UIMessage conversion layer, check for the malformed block shape and extract both fields:
System Info
@langchain/core: 1.1.39@langchain/openai: 1.4.4langchain: 1.x (monorepolangchainwrapper)gpt-5.2via OpenAI Responses API withreasoning: { effort: "medium", summary: "auto" }