mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user