diff --git a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts index 57cdf843fe..e740dc4327 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts @@ -41,11 +41,24 @@ describe("asNonEmptyString", () => { // --------------------------------------------------------------------------- describe("appendTextBlock", () => { - it("returns the same blocks when text is empty or whitespace", () => { + it("returns the same blocks for empty text", () => { const blocks: RenderBlock[] = [{ type: "response", text: "hello" }]; expect(appendTextBlock(blocks, "response", "")).toBe(blocks); - expect(appendTextBlock(blocks, "response", " ")).toBe(blocks); + expect(appendTextBlock(blocks, "thinking", "")).toBe(blocks); + }); + + it("does not start a block with whitespace-only text", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "hello" }]; expect(appendTextBlock(blocks, "thinking", "\n\t")).toBe(blocks); + const empty: RenderBlock[] = []; + expect(appendTextBlock(empty, "response", " ")).toBe(empty); + }); + + it("appends whitespace-only text to a matching block", () => { + const blocks: RenderBlock[] = [{ type: "response", text: "writing." }]; + expect(appendTextBlock(blocks, "response", "\n\n")).toEqual([ + { type: "response", text: "writing.\n\n" }, + ]); }); it("appends a new response block to an empty list", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts index 2b570259eb..b0b9870196 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts @@ -17,6 +17,10 @@ const textBlock = ( * Append a text or thinking block to a render block list, merging * with the previous block when the types match. Text never merges * across a provider-executed call or between narration and an answer. + * Whitespace-only text extends a matching block but never starts one: + * models stream paragraph breaks as separate "\n\n" deltas, and + * dropping them joins the paragraphs. Returns `blocks` unchanged when + * nothing is appended. */ export const appendTextBlock = ( blocks: RenderBlock[], @@ -24,15 +28,15 @@ export const appendTextBlock = ( text: string, narration = false, ): RenderBlock[] => { - if (!text.trim()) { - return blocks; - } - const nextBlocks = [...blocks]; - const last = nextBlocks[nextBlocks.length - 1]; + const last = blocks[blocks.length - 1]; const keepsApart = last?.type === "response" && (last.beforeProviderTool || (last.narration ?? false) !== narration); if (last && last.type === type && !keepsApart) { + if (!text) { + return blocks; + } + const nextBlocks = [...blocks]; nextBlocks[nextBlocks.length - 1] = textBlock( type, `${last.text}${text}`, @@ -40,8 +44,10 @@ export const appendTextBlock = ( ); return nextBlocks; } - nextBlocks.push(textBlock(type, text, narration)); - return nextBlocks; + if (!text.trim()) { + return blocks; + } + return [...blocks, textBlock(type, text, narration)]; }; type ToolGroupRenderBlock = { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 565151706a..e4af1f59e5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -250,6 +250,20 @@ describe("parseMessageContent", () => { }); }); + it("keeps whitespace-only parts between text parts", () => { + // Interrupted turns are saved with one part per streamed delta. + const result = parseMessageContent([ + { type: "text", text: "\n" }, + { type: "text", text: "writing." }, + { type: "text", text: "\n\n" }, + { type: "text", text: "Like this" }, + ]); + expect(result.markdown).toBe("writing.\n\nLike this"); + expect(result.blocks).toEqual([ + { type: "response", text: "writing.\n\nLike this" }, + ]); + }); + it("parses a reasoning block", () => { const result = parseMessageContent([ { type: "reasoning", text: "Let me think..." }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 7bde510ac0..83c55d8a39 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -19,9 +19,12 @@ import type { StreamState, } from "./types"; -/** Concatenate text chunks, skipping whitespace-only values. */ +/** + * Concatenate text chunks. A whitespace-only chunk is dropped only while the + * text is still empty, so paragraph breaks between chunks survive. + */ const appendText = (current: string, next: string): string => { - if (!next.trim()) { + if (!current && !next.trim()) { return current; } return `${current}${next}`; @@ -377,7 +380,8 @@ export const getEditableUserMessagePayload = ( fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined; } => { // Concatenate text parts verbatim to match the server-side string_agg in - // GetChatUserPromptsByChatID; parseMessageContent/appendText is for streaming and drops whitespace-only chunks. + // GetChatUserPromptsByChatID; parseMessageContent/appendText drops leading + // whitespace-only chunks. const text = (message.content ?? []) .filter((part): part is TypesGen.ChatTextPart => part.type === "text") .map((part) => part.text) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index b557df0267..17eba6e94d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -95,6 +95,22 @@ describe("applyMessagePartToStreamState", () => { expect(result).toBeNull(); }); + it.each([ + { partType: "text", blockType: "response" }, + { partType: "reasoning", blockType: "thinking" }, + ] as const)( + "keeps whitespace-only $partType deltas between paragraphs", + ({ partType, blockType }) => { + let state: StreamState | null = null; + for (const text of ["writing.", "\n\n", "Like this"]) { + state = applyMessagePartToStreamState(state, { type: partType, text }); + } + expect(state?.blocks).toEqual([ + { type: blockType, text: "writing.\n\nLike this" }, + ]); + }, + ); + it("returns prev for workspace file reference parts", () => { const prev = createEmptyStreamState(); const result = applyMessagePartToStreamState(prev, { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index a11f6de9c4..0c1a2fd5e8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -26,30 +26,28 @@ export const applyMessagePartToStreamState = ( switch (part.type) { case "text": { - // Skip empty and whitespace-only deltas so they don't - // create a non-null StreamState with empty blocks, which - // would prematurely end the "starting" phase. - if (!part.text.trim()) { + const blocks = appendTextBlock( + nextState.blocks, + "response", + part.text, + part.narration, + ); + // Keeps a leading whitespace-only delta from creating a non-null + // state with no blocks, which would end the "starting" phase early. + if (blocks === nextState.blocks) { return prev; } - return { - ...nextState, - blocks: appendTextBlock( - nextState.blocks, - "response", - part.text, - part.narration, - ), - }; + return { ...nextState, blocks }; } case "reasoning": { - if (!part.text.trim()) { + const blocks = appendTextBlock(nextState.blocks, "thinking", part.text); + if (blocks === nextState.blocks) { return prev; } return { ...nextState, startedAt: nextState.startedAt ?? part.created_at, - blocks: appendTextBlock(nextState.blocks, "thinking", part.text), + blocks, }; } case "tool-call": {