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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Nguyen DN 2026-06-19 20:46:59 +07:00 committed by GitHub
parent e7a03e5243
commit 654f5e1c66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 7 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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.",
);
});
});

View File

@ -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();
});