fix(agent-chat): use direct concatenation for completed message text blocks (#23009)

## Problem

LLMs stream list markers and item content as separate text blocks:

```json
{ "type": "text", "text": "Intro\n\n- " }
{ "type": "text", "text": "First item" }
{ "type": "text", "text": "\n- " }
{ "type": "text", "text": "Second item" }
```

The **streaming path** concatenated these directly → `"- First item"` ✅

The **completed path** used `appendText` which inserted `\n` between
chunks → `"- \nFirst item"` ❌

Every CommonMark parser treats `"- \nText"` (marker and content on
different lines, content not indented) as an empty `<li>` followed by a
sibling `<p>`, producing broken list rendering once a message finished
streaming.

## Fix

Make `appendText` use direct concatenation — the same as the streaming
path. The API text blocks already contain all necessary whitespace and
newlines; inserting extra `\n` between them was the bug.

## Changes

- **`messageParsing.ts`** — `appendText` simplified to direct concat
(skip whitespace-only chunks). `appendParsedTextBlock` no longer passes
a custom joiner, so it uses the same default as the streaming path.
- **`messageParsing.test.ts`** — Updated existing merge test
expectation; added regression test with the exact LLM list-marker
payload.
This commit is contained in:
Kyle Carberry
2026-03-12 18:23:36 +00:00
committed by GitHub
parent b1e80e6f3a
commit 1ac5418fc4
2 changed files with 25 additions and 10 deletions
@@ -101,11 +101,28 @@ describe("parseMessageContent", () => {
{ type: "text", text: "Line one" },
{ type: "text", text: "Line two" },
]);
expect(result.markdown).toBe("Line one\nLine two");
expect(result.markdown).toBe("Line oneLine two");
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0]).toEqual({
type: "response",
text: "Line one\nLine two",
text: "Line oneLine two",
});
});
it("normalizes list markers split across text blocks", () => {
// LLMs stream list markers and item content as separate text
// blocks. Both paths (streaming and completed) must concatenate
// them directly so the marker and content stay on the same line.
const result = parseMessageContent([
{ type: "text", text: "Intro\n\n- " },
{ type: "text", text: "First item" },
{ type: "text", text: "\n- " },
{ type: "text", text: "Second item" },
]);
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0]).toEqual({
type: "response",
text: "Intro\n\n- First item\n- Second item",
});
});
@@ -11,15 +11,12 @@ import type {
RenderBlock,
} from "./types";
/** Concatenate text chunks, skipping whitespace-only values. */
const appendText = (current: string, next: string): string => {
const trimmed = next.trim();
if (!trimmed) {
if (!next.trim()) {
return current;
}
if (!current) {
return next;
}
return `${current}\n${next}`;
return `${current}${next}`;
};
export const asOptionalTitle = (value: unknown): string | undefined =>
@@ -79,13 +76,14 @@ const emptyParsedMessageContent = (): ParsedMessageContent => ({
sources: [],
});
/** Wraps appendTextBlock with newline-joining for complete message blocks. */
/** Wraps appendTextBlock using the same direct concatenation as
* the streaming path so both produce identical markdown. */
const appendParsedTextBlock = (
blocks: RenderBlock[],
type: "response" | "thinking",
text: string,
title?: string,
): RenderBlock[] => appendTextBlock(blocks, type, text, title, appendText);
): RenderBlock[] => appendTextBlock(blocks, type, text, title);
export const ensureToolBlock = (
blocks: RenderBlock[],