feat(FN-3817): fix chat delta spacing in legacy streaming API

Fixes chat streaming delta spacing to preserve proper whitespace, adding regression tests for legacy chat stream handlers and the createChatStreamHandlers factory. A changeset was created for the `@runfusion/fusion` package.

Fusion-Task-Id: FN-3817
This commit is contained in:
Fusion
2026-05-11 00:49:25 -07:00
committed by gsxdsm
parent 544383ab3f
commit ef3281bc9d
4 changed files with 117 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Preserve whitespace at SSE delta boundaries in dashboard chat streaming so streamed multi-sentence assistant responses render `. ` correctly between sentences in ChatView and QuickChatFAB.

View File

@@ -139,6 +139,65 @@ describe("streamChatResponse SSE parser", () => {
expect(onError).toHaveBeenCalledWith("Timed out waiting for first response event");
vi.useRealTimers();
});
it.each([
{
name: "leading-space second delta",
chunks: [
"event: text\n",
"data: \"Hello.\"\n\n",
"event: text\n",
"data: \" World.\"\n\n",
],
},
{
name: "space-trailing first delta",
chunks: [
"event: text\n",
"data: \"Hello. \"\n\n",
"event: text\n",
"data: \"World.\"\n\n",
],
},
{
name: "empty delta between spaced chunks",
chunks: [
"event: text\n",
"data: \"Hello.\"\n\n",
"event: text\n",
"data: \"\"\n\n",
"event: text\n",
"data: \" World.\"\n\n",
],
},
{
name: "chunk boundary mid-json of second delta",
chunks: [
"event: text\ndata: \"Hello.\"\n\nevent: text\ndata: \"",
" World.\"\n\n",
],
},
{
name: "chunk boundary inside leading space on data line",
chunks: [
"event: text\ndata: \"Hello.\"\n\nevent: text\ndata: \" ",
"World.\"\n\n",
],
},
])("preserves whitespace at SSE delta boundaries: $name", async ({ chunks }) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(createChunkedStream(chunks), { status: 200 }));
const textChunks: string[] = [];
streamChatResponse("s-1", "hi", {
onText: (data) => textChunks.push(data),
onError: vi.fn(),
});
await vi.waitFor(() => {
expect(textChunks.join("")).toBe("Hello. World.");
});
});
});
describe("attachChatStream", () => {

View File

@@ -8580,6 +8580,8 @@ export function streamChatResponse(
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
const value = line.slice(5);
// Strip only the optional SSE protocol delimiter-space after `data:`.
// Payload whitespace (including JSON-string leading spaces) must stay verbatim.
currentDataLines.push(value.startsWith(" ") ? value.slice(1) : value);
} else if (line === "") {
const currentData = currentDataLines.join("\n");
@@ -8780,6 +8782,8 @@ export function attachChatStream(
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
const value = line.slice(5);
// Strip only the optional SSE protocol delimiter-space after `data:`.
// Payload whitespace (including JSON-string leading spaces) must stay verbatim.
currentDataLines.push(value.startsWith(" ") ? value.slice(1) : value);
} else if (line === "") {
const currentData = currentDataLines.join("\n");

View File

@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { createChatStreamHandlers } from "../createChatStreamHandlers";
describe("createChatStreamHandlers", () => {
it("preserves whitespace across streamed text deltas", () => {
vi.useFakeTimers();
let text = "";
const onDone = vi.fn();
const onError = vi.fn();
const cancelStreamingFlushesRef = { current: null } as { current: (() => void) | null };
const { handlers } = createChatStreamHandlers({
sessionId: "s-1",
tempUserMessageId: "temp-1",
setStreamingText: (value) => {
text = typeof value === "function" ? value(text) : value;
},
setStreamingThinking: vi.fn(),
setStreamingToolCalls: vi.fn(),
cancelStreamingFlushesRef,
onDone,
onError,
});
handlers.onText("Hello.");
handlers.onText("");
handlers.onText(" World.");
vi.advanceTimersToNextTimer();
expect(text).toBe("Hello. World.");
handlers.onDone({ messageId: "m-1" });
expect(onDone).toHaveBeenCalledWith({
messageId: "m-1",
message: undefined,
accumulated: {
text: "Hello. World.",
thinking: "",
toolCalls: [],
fallbackInfo: undefined,
},
});
expect(onError).not.toHaveBeenCalled();
vi.useRealTimers();
});
});