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:
5
.changeset/fn-3817-chat-delta-spacing.md
Normal file
5
.changeset/fn-3817-chat-delta-spacing.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user