diff --git a/langchain-core/src/messages/base.ts b/langchain-core/src/messages/base.ts index 41a7cb2c2f70..47b4374c9f0f 100644 --- a/langchain-core/src/messages/base.ts +++ b/langchain-core/src/messages/base.ts @@ -127,8 +127,12 @@ export function mergeContent( } // If both are arrays } else if (Array.isArray(secondContent)) { - return [...firstContent, ...secondContent]; - // If the first content is a list and second is a string + return ( + _mergeLists(firstContent, secondContent) ?? [ + ...firstContent, + ...secondContent, + ] + ); } else { // Otherwise, add the second content as a new element of the list return [...firstContent, { type: "text", text: secondContent }]; @@ -259,8 +263,12 @@ export function _mergeDicts( `field[${key}] already exists in the message chunk, but with a different type.` ); } else if (typeof merged[key] === "string") { - merged[key] = (merged[key] as string) + value; - } else if (!Array.isArray(merged[key]) && typeof merged[key] === "object") { + if (key === "type") { + // Do not merge 'type' fields + continue; + } + merged[key] += value; + } else if (typeof merged[key] === "object" && !Array.isArray(merged[key])) { merged[key] = _mergeDicts(merged[key], value); } else if (Array.isArray(merged[key])) { merged[key] = _mergeLists(merged[key], value); @@ -297,6 +305,13 @@ export function _mergeLists(left?: any[], right?: any[]) { } else { merged.push(item); } + } else if ( + typeof item === "object" && + "text" in item && + item.text === "" + ) { + // No-op - skip empty text blocks + continue; } else { merged.push(item); } diff --git a/langchain-core/src/messages/tests/base_message.test.ts b/langchain-core/src/messages/tests/base_message.test.ts index fb6f3797f31f..2a46f2fe014e 100644 --- a/langchain-core/src/messages/tests/base_message.test.ts +++ b/langchain-core/src/messages/tests/base_message.test.ts @@ -1,10 +1,11 @@ -import { test } from "@jest/globals"; +import { test, describe, it, expect } from "@jest/globals"; import { ChatPromptTemplate } from "../../prompts/chat.js"; import { HumanMessage, AIMessage, ToolMessage, ToolMessageChunk, + AIMessageChunk, } from "../index.js"; import { load } from "../../load/index.js"; @@ -193,3 +194,143 @@ test("Can concat raw_output (object) of ToolMessageChunk", () => { bar: "baz", }); }); + +describe("Complex AIMessageChunk concat", () => { + it("concatenates content arrays of strings", () => { + expect( + new AIMessageChunk({ + content: [{ type: "text", text: "I am" }], + id: "ai4", + }).concat( + new AIMessageChunk({ content: [{ type: "text", text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ + id: "ai4", + content: [ + { type: "text", text: "I am" }, + { type: "text", text: " indeed." }, + ], + }) + ); + }); + + it("concatenates mixed content arrays", () => { + expect( + new AIMessageChunk({ + content: [{ index: 0, type: "text", text: "I am" }], + }).concat( + new AIMessageChunk({ content: [{ type: "text", text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ + content: [ + { index: 0, type: "text", text: "I am" }, + { type: "text", text: " indeed." }, + ], + }) + ); + }); + + it("merges content arrays with same index", () => { + expect( + new AIMessageChunk({ content: [{ index: 0, text: "I am" }] }).concat( + new AIMessageChunk({ content: [{ index: 0, text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ content: [{ index: 0, text: "I am indeed." }] }) + ); + }); + + it("does not merge when one chunk is missing an index", () => { + expect( + new AIMessageChunk({ content: [{ index: 0, text: "I am" }] }).concat( + new AIMessageChunk({ content: [{ text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ + content: [{ index: 0, text: "I am" }, { text: " indeed." }], + }) + ); + }); + + it("does not create a holey array when there's a gap between indexes", () => { + expect( + new AIMessageChunk({ content: [{ index: 0, text: "I am" }] }).concat( + new AIMessageChunk({ content: [{ index: 2, text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ + content: [ + { index: 0, text: "I am" }, + { index: 2, text: " indeed." }, + ], + }) + ); + }); + + it("does not merge content arrays with separate indexes", () => { + expect( + new AIMessageChunk({ content: [{ index: 0, text: "I am" }] }).concat( + new AIMessageChunk({ content: [{ index: 1, text: " indeed." }] }) + ) + ).toEqual( + new AIMessageChunk({ + content: [ + { index: 0, text: "I am" }, + { index: 1, text: " indeed." }, + ], + }) + ); + }); + + it("merges content arrays with same index and type", () => { + expect( + new AIMessageChunk({ + content: [{ index: 0, text: "I am", type: "text_block" }], + }).concat( + new AIMessageChunk({ + content: [{ index: 0, text: " indeed.", type: "text_block" }], + }) + ) + ).toEqual( + new AIMessageChunk({ + content: [{ index: 0, text: "I am indeed.", type: "text_block" }], + }) + ); + }); + + it("merges content arrays with same index and different types without updating type", () => { + expect( + new AIMessageChunk({ + content: [{ index: 0, text: "I am", type: "text_block" }], + }).concat( + new AIMessageChunk({ + content: [{ index: 0, text: " indeed.", type: "text_block_delta" }], + }) + ) + ).toEqual( + new AIMessageChunk({ + content: [{ index: 0, text: "I am indeed.", type: "text_block" }], + }) + ); + }); + + it("concatenates empty string content and merges other fields", () => { + expect( + new AIMessageChunk({ + content: [{ index: 0, type: "text", text: "I am" }], + }).concat( + new AIMessageChunk({ + content: [{ type: "text", text: "" }], + response_metadata: { extra: "value" }, + }) + ) + ).toEqual( + new AIMessageChunk({ + content: [{ index: 0, type: "text", text: "I am" }], + response_metadata: { extra: "value" }, + }) + ); + }); +});