Skip to content

AIMessageChunk.concat() merges reasoning and text content blocks with the same index into a single malformed block, losing the text response #10691

@mgssf

Description

@mgssf

Checked other resources

  • This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
  • I added a very descriptive title to this issue.
  • I searched the LangChain.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain.js rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).

Example Code

  1. Use @langchain/openai with the Responses API and a reasoning model (e.g. gpt-5.2 with reasoning: { effort: "medium", summary: "auto" })
  2. Stream a response that includes both a reasoning summary and a text output
  3. 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" }

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions