From b33676d16ac1d0fd839dc8722ab2213ab66a229a Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 13 Jul 2026 22:24:20 -0400 Subject: [PATCH] SMOODEV-2534: fix transient double-newline during streaming The streamed token render (raw accumulation) and the finalized render (responseParts.join) were two independent paths that could disagree on blank-line count mid-stream, showing an extra blank line that vanished when the message finalized. Share one PARAGRAPH_SEP + normalizeParagraphs between both paths so the streaming render matches the finalized render; the finalized output stays byte-identical for well-formed responses. Co-Authored-By: Claude Opus 4.8 --- .changeset/streaming-newline-fix.md | 9 ++++ src/conversation.test.ts | 74 ++++++++++++++++++++++++++++- src/conversation.ts | 34 +++++++++++-- 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 .changeset/streaming-newline-fix.md diff --git a/.changeset/streaming-newline-fix.md b/.changeset/streaming-newline-fix.md new file mode 100644 index 0000000..b1bf785 --- /dev/null +++ b/.changeset/streaming-newline-fix.md @@ -0,0 +1,9 @@ +--- +'@smooai/chat-widget': patch +--- + +Fix a transient double-newline (extra blank line) during streaming. The streamed +token render and the finalized `responseParts` render now share one paragraph +separator and normalizer, so mid-stream no longer shows an extra blank line that +vanishes when the message finalizes. The finalized output is unchanged for +well-formed responses. diff --git a/src/conversation.test.ts b/src/conversation.test.ts index 8381f24..90b1b5c 100644 --- a/src/conversation.test.ts +++ b/src/conversation.test.ts @@ -17,7 +17,7 @@ * - cross-device request → verify → resolve → replay over the HTTP routes. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConversationController, type ConversationEvents, httpBaseFromWsEndpoint, type IdentityRestore } from './conversation.js'; +import { ConversationController, type ConversationEvents, httpBaseFromWsEndpoint, type IdentityRestore, normalizeParagraphs } from './conversation.js'; import { createWidgetStore } from './persistence.js'; const ENDPOINT = 'wss://example.test/ws'; @@ -362,6 +362,78 @@ describe('ConversationController — suggested replies (from eventual_response)' }); }); +describe('normalizeParagraphs', () => { + it('collapses 3+ newlines to a single paragraph break', () => { + expect(normalizeParagraphs('A\n\n\nB')).toBe('A\n\nB'); + expect(normalizeParagraphs('A\n\n\n\n\nB')).toBe('A\n\nB'); + }); + it('is a no-op on well-formed text (single break or paragraph break)', () => { + expect(normalizeParagraphs('A\nB')).toBe('A\nB'); + expect(normalizeParagraphs('A\n\nB')).toBe('A\n\nB'); + // Idempotent — the finalized join it wraps stays byte-identical. + const joined = ['A', 'B'].join('\n\n'); + expect(normalizeParagraphs(joined)).toBe(joined); + }); +}); + +describe('ConversationController — streaming render matches finalized render (SMOODEV-2534)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + MockSocket.onFrame = defaultOnFrame; + }); + afterEach(() => localStorage.clear()); + + it('never shows an extra blank line mid-stream: streamed text agrees with the finalized join', async () => { + // The model streams paragraph one, then an extra blank line (a `\n\n\n` run + // split across tokens), then paragraph two. The finalized `responseParts` + // join uses `\n\n`. Mid-stream must never render more blank lines than the + // finalized message. + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + if (frame.action === 'send_message') { + reply({ type: 'immediate_response', requestId, status: 202, data: {} }); + for (const token of ['Para one.', '\n\n', '\n', 'Para two.']) { + reply({ type: 'stream_token', requestId, token }); + } + reply({ + type: 'eventual_response', + requestId, + status: 200, + data: { requestId, status: 200, data: { messageId: 'm1', response: { responseParts: ['Para one.', 'Para two.'] } } }, + }); + } else { + defaultOnFrame(frame, reply); + } + }; + + const { controller, onMessages } = makeController(); + await controller.connect(); + await controller.send('hello'); + + type Snap = Array<{ role: string; streaming: boolean; text: string }>; + const assistantSnaps = onMessages.mock.calls + .map((c) => (c[0] as Snap).at(-1)) + .filter((m): m is { role: string; streaming: boolean; text: string } => m?.role === 'assistant'); + + // No emitted assistant snapshot — streaming or final — carries a 3+ newline run. + for (const snap of assistantSnaps) { + expect(snap.text).not.toMatch(/\n{3,}/); + } + + const finalText = ['Para one.', 'Para two.'].join('\n\n'); + // Finalized output is byte-identical to the raw `responseParts.join('\n\n')`. + const finalized = assistantSnaps.at(-1)!; + expect(finalized.streaming).toBe(false); + expect(finalized.text).toBe(finalText); + // The last mid-stream render already agrees with the finalized render — no + // jump / flicker when the terminal event lands. + const lastStreaming = assistantSnaps.filter((s) => s.streaming).at(-1)!; + expect(lastStreaming.text).toBe(finalText); + }); +}); + describe('httpBaseFromWsEndpoint', () => { it('derives the HTTP base from a wss /ws endpoint', () => { expect(httpBaseFromWsEndpoint('wss://ai.smoo.ai/ws')).toBe('https://ai.smoo.ai'); diff --git a/src/conversation.ts b/src/conversation.ts index 724f393..cff941e 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -208,12 +208,33 @@ export interface ConversationEvents { onIdentityRestore?: (state: IdentityRestore) => void; } +/** + * Paragraph separator the server uses to join `responseParts`. The streaming + * render (raw-token accumulation) and the finalized render (this join) MUST use + * the same separation, or a transient extra blank line flickers mid-stream and + * then vanishes when the terminal `eventual_response` re-renders (SMOODEV-2534). + */ +const PARAGRAPH_SEP = '\n\n'; + +/** + * Collapse any run of 3+ newlines down to a single paragraph break. Idempotent, + * and a no-op on text already separated by exactly `\n\n` — so applying it to the + * finalized `responseParts.join(PARAGRAPH_SEP)` leaves well-formed responses + * byte-identical, while the streaming accumulator (which sees whatever raw + * newlines the model emits token-by-token) is held to the same paragraph shape + * the finalized render will use. That keeps mid-stream from showing an extra + * blank line the finalized message doesn't. + */ +export function normalizeParagraphs(text: string): string { + return text.replace(/\n{3,}/g, PARAGRAPH_SEP); +} + /** Pull the final assistant text out of an `eventual_response` data payload. */ function extractFinalText(response: unknown): string | null { if (!response || typeof response !== 'object') return null; const r = response as { responseParts?: unknown }; if (Array.isArray(r.responseParts)) { - return r.responseParts.filter((p): p is string => typeof p === 'string').join('\n\n'); + return normalizeParagraphs(r.responseParts.filter((p): p is string => typeof p === 'string').join(PARAGRAPH_SEP)); } return null; } @@ -283,8 +304,9 @@ const nextToolId = (): string => `tool-${++toolSeq}`; function growTextBlock(blocks: MessageBlock[], text: string): void { if (!text) return; const last = blocks[blocks.length - 1]; - if (last && last.kind === 'text') last.text += text; - else blocks.push({ kind: 'text', text }); + // Same paragraph normalization as the plain-text stream path (SMOODEV-2534). + if (last && last.kind === 'text') last.text = normalizeParagraphs(last.text + text); + else blocks.push({ kind: 'text', text: normalizeParagraphs(text) }); } /** @@ -786,7 +808,11 @@ export class ConversationController { if (event.type === 'stream_token') { const token = event.token ?? event.data?.token ?? ''; if (token) { - assistant.text += token; + // Hold the streamed text to the same paragraph shape the finalized + // render uses (PARAGRAPH_SEP) so no transient extra blank line + // flickers mid-stream then vanishes on finalize (SMOODEV-2534). + // ponytail: O(n) re-scan per token; fine for chat-length replies. + assistant.text = normalizeParagraphs(assistant.text + token); // Grow the trailing text block so prose interleaves with any // tool chips in the order the model produced them. if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);