Files
fusion/packages/dashboard/app/hooks/__tests__/createChatStreamHandlers.test.ts
gsxdsm 3d28b3b212 FN-6632: preserve reattached chat stream chunks
Seed streaming accumulators from durable in-flight snapshots so reattached chat bubbles keep prior chunks.

- Add initial text, thinking, and tool-call snapshots to chat stream handlers.
- Wire main chat and QuickChat reattach flows to seed handler accumulators before replayed deltas arrive.
- Cover text, thinking, and tool-call continuation across shared handler, main chat, and QuickChat tests.
- Document the reattach accumulator invariant and add a published package changeset.

Files changed:
 .changeset/fn-6632-chat-stream-reattach.md         |   5 +
 docs/architecture.md                               |   2 +-
 docs/dashboard-guide.md                            |   2 +-
 .../__tests__/createChatStreamHandlers.test.ts     |  63 +++++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 182 +++++++++++++++++++++
 .../app/hooks/__tests__/useQuickChat.test.ts       | 147 +++++++++++++++++
 .../app/hooks/createChatStreamHandlers.ts          |  19 ++-
 packages/dashboard/app/hooks/useChat.ts            |   7 +
 packages/dashboard/app/hooks/useQuickChat.ts       |   7 +
 9 files changed, 429 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-6632
Fusion-Task-Lineage: e2f2eb42-08aa-4bae-a79f-60cfc9fb03d4
2026-06-18 06:36:03 -07:00

129 lines
3.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createChatStreamHandlers } from "../createChatStreamHandlers";
import type { ToolCallInfo } from "../chatTypes";
describe("createChatStreamHandlers", () => {
it.each([
{
name: "empty delta sandwiched between spaced chunks",
chunks: ["Hello.", "", " World."],
expected: "Hello. World.",
},
{
name: "multiple sentence boundaries across four chunks",
chunks: ["One.", " Two.", " Three.", " Four."],
expected: "One. Two. Three. Four.",
},
{
name: "whitespace-only final delta immediately before done",
chunks: ["Trailing", " "],
expected: "Trailing ",
},
])("preserves whitespace across streamed text deltas (%s)", ({ chunks, expected }) => {
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,
});
for (const chunk of chunks) {
handlers.onText(chunk);
}
vi.advanceTimersToNextTimer();
expect(text).toBe(expected);
handlers.onDone({ messageId: "m-1" });
expect(onDone).toHaveBeenCalledWith({
messageId: "m-1",
message: undefined,
accumulated: {
text: expected,
thinking: "",
toolCalls: [],
fallbackInfo: undefined,
},
});
expect(onError).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("FN-6632 seeds reattached accumulators before appending new chunks", () => {
vi.useFakeTimers();
let text = "Hello ";
let thinking = "thinking…";
let toolCalls: ToolCallInfo[] = [
{ toolName: "read", status: "completed", isError: false, result: "seeded" },
];
const onDone = vi.fn();
const cancelStreamingFlushesRef = { current: null } as { current: (() => void) | null };
const { handlers } = createChatStreamHandlers({
sessionId: "s-1",
tempUserMessageId: "",
initialText: "Hello ",
initialThinking: "thinking…",
initialToolCalls: toolCalls,
setStreamingText: (value) => {
text = typeof value === "function" ? value(text) : value;
},
setStreamingThinking: (value) => {
thinking = typeof value === "function" ? value(thinking) : value;
},
setStreamingToolCalls: (value) => {
toolCalls = typeof value === "function" ? value(toolCalls) : value;
},
cancelStreamingFlushesRef,
onDone,
onError: vi.fn(),
});
handlers.onText("world");
handlers.onText("!");
handlers.onThinking(" more");
handlers.onToolStart({ toolName: "write", args: { path: "a.ts" } });
handlers.onToolEnd({ toolName: "write", isError: false, result: "done" });
vi.advanceTimersToNextTimer();
vi.advanceTimersToNextTimer();
expect(text).toBe("Hello world!");
expect(thinking).toBe("thinking… more");
expect(toolCalls).toEqual([
{ toolName: "read", status: "completed", isError: false, result: "seeded" },
{ toolName: "write", args: { path: "a.ts" }, status: "completed", isError: false, result: "done" },
]);
handlers.onDone({ messageId: "m-1" });
expect(onDone).toHaveBeenCalledWith({
messageId: "m-1",
message: undefined,
accumulated: {
text: "Hello world!",
thinking: "thinking… more",
toolCalls,
fallbackInfo: undefined,
},
});
vi.useRealTimers();
});
});