From 1ac5418fc4ed49f1b75c856173678a8c4bb3d035 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 12 Mar 2026 11:23:36 -0700 Subject: [PATCH] fix(agent-chat): use direct concatenation for completed message text blocks (#23009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `
  • ` followed by a sibling `

    `, 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. --- .../AgentDetail/messageParsing.test.ts | 21 +++++++++++++++++-- .../AgentsPage/AgentDetail/messageParsing.ts | 14 ++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts index e764d8b16d..30c3b90a3c 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts @@ -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", }); }); diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts index e86aa24446..8e29b5b686 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts @@ -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[],