From 654f5e1c6656a8d0abb5e6350669aaab4bd53822 Mon Sep 17 00:00:00 2001 From: Nguyen DN Date: Fri, 19 Jun 2026 20:46:59 +0700 Subject: [PATCH] fix(frontend): render full content for multi-part AI messages (#3649) * fix(frontend): render full content for multi-part AI messages Gemini streams the first content block as a {type:text} object carrying the thinking signature, then emits continuation deltas as bare strings. The content extractors only kept {type:text} objects and dropped the string parts, truncating each AI message to its first block. Handle string elements in extractContentFromMessage, extractTextFromMessage, and textOfMessage so the full message renders. Fixes #1000 * test(frontend): cover multi-part AI message content extraction Add a regression test for the #1000 truncation: an AI message whose content is [{type:text, signature}, "bare string"] (Gemini's shape after LangChain merge_content). Fails on main, passes with the extractor fix. Co-Authored-By: Claude Opus 4.8 (1M context) * test(frontend): cover textOfMessage multi-part content extraction Add tests for the bare-string continuation case and the null-when-empty contract, and document why textOfMessage joins flat ("") while the body extractors join with "\n". Addresses review feedback on #3649. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/core/messages/utils.ts | 11 +++++- frontend/src/core/threads/utils.ts | 13 ++++--- .../tests/unit/core/messages/utils.test.ts | 33 ++++++++++++++++ .../tests/unit/core/threads/utils.test.ts | 39 ++++++++++++++++++- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 9592db8b4..3ced9d3f4 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -259,7 +259,13 @@ export function extractTextFromMessage(message: Message) { } if (Array.isArray(message.content)) { return message.content - .map((content) => (content.type === "text" ? content.text : "")) + .map((content) => + typeof content === "string" + ? content + : content.type === "text" + ? content.text + : "", + ) .join("\n") .trim(); } @@ -323,6 +329,9 @@ export function extractContentFromMessage(message: Message) { if (Array.isArray(message.content)) { return message.content .map((content) => { + if (typeof content === "string") { + return content; + } switch (content.type) { case "text": return content.text; diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index 7a5b048c0..b1b8af119 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -43,11 +43,14 @@ export function textOfMessage(message: Message) { if (typeof message.content === "string") { return message.content; } else if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === "text") { - return part.text; - } - } + // Flat join ("") for single-line consumers (input box, titles); the rendered + // body uses extractContentFromMessage, which joins multi-part content with "\n". + const text = message.content + .map((part) => + typeof part === "string" ? part : part.type === "text" ? part.text : "", + ) + .join(""); + return text.length > 0 ? text : null; } return null; } diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 510e43a05..cba5f37fa 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { extractContentFromMessage, + extractTextFromMessage, extractReasoningContentFromMessage, getAssistantTurnCopyData, getAssistantTurnUsageMessages, @@ -508,3 +509,35 @@ test("keeps streaming assistant hidden when a hidden control message follows it" ), ).toBe(true); }); + +describe("multi-part content with bare-string continuations", () => { + // Gemini streams the first content block as a {type:"text"} object carrying + // the thinking signature, then emits continuation deltas as plain strings. + // LangChain's Python merge_content preserves these as bare-string elements, + // so the finalized message content is [{type:"text", ...}, "...rest..."]. + const geminiMessage = { + id: "ai-1", + type: "ai", + content: [ + { + type: "text", + text: "First block carrying the signature.", + extras: { signature: "abc123" }, + index: 0, + }, + "Continuation streamed as a bare string.", + ], + } as unknown as Message; + + test("extractContentFromMessage includes the bare-string parts", () => { + expect(extractContentFromMessage(geminiMessage)).toBe( + "First block carrying the signature.\nContinuation streamed as a bare string.", + ); + }); + + test("extractTextFromMessage includes the bare-string parts", () => { + expect(extractTextFromMessage(geminiMessage)).toBe( + "First block carrying the signature.\nContinuation streamed as a bare string.", + ); + }); +}); diff --git a/frontend/tests/unit/core/threads/utils.test.ts b/frontend/tests/unit/core/threads/utils.test.ts index bc600fa3b..bc5a684be 100644 --- a/frontend/tests/unit/core/threads/utils.test.ts +++ b/frontend/tests/unit/core/threads/utils.test.ts @@ -1,6 +1,11 @@ +import type { Message } from "@langchain/langgraph-sdk"; import { expect, test } from "vitest"; -import { channelSourceOfThread, pathOfThread } from "@/core/threads/utils"; +import { + channelSourceOfThread, + pathOfThread, + textOfMessage, +} from "@/core/threads/utils"; test("uses standard chat route when thread has no agent context", () => { expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123"); @@ -81,3 +86,35 @@ test("ignores threads without valid IM channel source metadata", () => { }), ).toBeNull(); }); + +test("textOfMessage concatenates object and bare-string content parts", () => { + // Gemini's finalized shape: first signed {type:text} block + bare-string + // continuation. textOfMessage joins flat ("") for single-line consumers. + const message = { + id: "ai-1", + type: "ai", + content: [ + { + type: "text", + text: "First block.", + extras: { signature: "abc123" }, + index: 0, + }, + " Continuation as a bare string.", + ], + } as unknown as Message; + + expect(textOfMessage(message)).toBe( + "First block. Continuation as a bare string.", + ); +}); + +test("textOfMessage returns null when array content has no text", () => { + const message = { + id: "ai-1", + type: "ai", + content: [{ type: "image_url", image_url: "https://example.com/x.png" }], + } as unknown as Message; + + expect(textOfMessage(message)).toBeNull(); +});