Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/streaming-newline-fix.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 73 additions & 1 deletion src/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
34 changes: 30 additions & 4 deletions src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) });
}

/**
Expand Down Expand Up @@ -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);
Expand Down
Loading