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
129 lines
3.8 KiB
TypeScript
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();
|
|
});
|
|
});
|