Skip to content

bug(@langchain/core): AIMessageChunk .concat() method doesn't join text chunks into one after first non-text chunk. #10561

@vjancik

Description

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

/**
 * Dev script for testing Gemini's built-in code execution tool.
 * Invokes (or streams) the model with a prompt that requires computation and saves the
 * raw AIMessage / AIMessageChunk JSON to timestamped files in this directory.
 *
 * Run via: bun run gemini-code-execution-test.ts
 */

import { type AIMessageChunk, HumanMessage, type MessageOutputVersion } from "@langchain/core/messages";
import { ChatGoogle } from "@langchain/google/node";

const MODEL = "gemini-2.5-flash";
const PROMPT = "Render the normal distribution of IQ scores into a graphic for me";

function buildLlm(outputVersion: MessageOutputVersion) {
    return new ChatGoogle({
        model: MODEL,
        outputVersion,
    }).bindTools([{ codeExecution: {} }]);
}

const llms: Record<MessageOutputVersion, ReturnType<typeof buildLlm>> = {
    v0: buildLlm("v0"),
    v1: buildLlm("v1"),
};

function makeBasePath(outputVersion: MessageOutputVersion, mode: "invoke" | "stream"): string {
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    return `${import.meta.dir}/gemini-code-execution-${timestamp}-${outputVersion}-${mode}`;
}

async function runInvoke(outputVersion: MessageOutputVersion): Promise<void> {
    const llm = llms[outputVersion];

    console.log(`[invoke] outputVersion="${outputVersion}" — "${PROMPT}"`);

    const result = await llm.invoke([new HumanMessage(PROMPT)]);
    const outPath = `${makeBasePath(outputVersion, "invoke")}.json`;

    await Bun.write(outPath, JSON.stringify(result.toJSON(), null, 2));
    console.log(`Saved: ${outPath}`);
}

async function collectStream(outputVersion: MessageOutputVersion): Promise<AIMessageChunk[]> {
    const llm = llms[outputVersion];

    console.log(`[stream] outputVersion="${outputVersion}" — "${PROMPT}"`);

    const chunks: AIMessageChunk[] = [];
    let firstChunkAt: number | null = null;
    const invokedAt = Date.now();
    for await (const chunk of await llm.stream([new HumanMessage(PROMPT)])) {
        firstChunkAt ??= Date.now();
        chunks.push(chunk);
    }
    const now = Date.now();
    const streamDurationMs = firstChunkAt !== null ? now - firstChunkAt : 0;
    const totalDurationMs = now - invokedAt;
    console.log(`Stream duration (first → last chunk): ${streamDurationMs}ms`);
    console.log(`Total response time (invoke → last chunk): ${totalDurationMs}ms`);
    return chunks;
}

async function saveChunks(chunks: AIMessageChunk[], basePath: string): Promise<void> {
    const outPath = `${basePath}-chunks.json`;
    await Bun.write(
        outPath,
        JSON.stringify(
            chunks.map((c) => c.toJSON()),
            null,
            2,
        ),
    );
    console.log(`Saved: ${outPath}`);
}

async function saveCollected(chunks: AIMessageChunk[], basePath: string): Promise<void> {
    const [first, ...rest] = chunks;
    if (!first) throw new Error("No chunks received from stream");
    const collected = rest.reduce((acc, chunk) => acc.concat(chunk), first);
    const outPath = `${basePath}-collected.json`;
    await Bun.write(outPath, JSON.stringify(collected.toJSON(), null, 2));
    console.log(`Saved: ${outPath}`);
}

async function runStream(outputVersion: MessageOutputVersion): Promise<void> {
    const chunks = await collectStream(outputVersion);
    const basePath = makeBasePath(outputVersion, "stream");
    await Promise.all([saveChunks(chunks, basePath), saveCollected(chunks, basePath)]);
}

async function runCodeExecution(outputVersion: MessageOutputVersion, stream = false): Promise<void> {
    if (stream) {
        await runStream(outputVersion);
    } else {
        await runInvoke(outputVersion);
    }
}

await runCodeExecution("v0", true);

// await runCodeExecution("v1", true);

Error Message and Stack Trace (if applicable)

No response

Description

As you can see from the example output:

  • chunks is .stream chunks serialized as an array
  • collected is .stream chunks concatenated using chunk.concat(newChunk) then serialized using .toJSON()

In the first case (first pair or files):
The "collected" AIMessageChunk is correctly one text part. And it equals the originalTextContentBlock.

In the second case (second pair of files):
The "collected" AIMessageChunk collects text parts up to the first non-text part, then it just appends the rest as they were in the original chunks. The originalTextContentBlock is still correct.

I believe the original intention was for ALL consecutive text type content part blocks to be collapsed into one on .concat(), not just until the first non-text block.

gemini-code-execution-2026-03-31T13-29-50-785Z-v0-stream-chunks.json
gemini-code-execution-2026-03-31T13-29-50-785Z-v0-stream-collected.json
gemini-code-execution-2026-03-31T13-32-46-251Z-v0-stream-chunks.json
gemini-code-execution-2026-03-31T13-32-46-251Z-v0-stream-collected.json

System Info

Package: @langchain/google
Version: 0.1.9
Companion: @langchain/core 1.1.37 (peerDependency enforced)
Runtime: bun 1.3.11
Platform: Ubuntu 25.10 (Linux 6.17.0-14-generic)

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