Optimize WebUI streaming and long history rendering
Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions. Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: `thinking${extraReasoning}`,
|
||||
reasoningStreaming: true,
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
if (extraTool) rows.push(extraTool);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function installAnimationFrameQueue() {
|
||||
const originalRequest = window.requestAnimationFrame;
|
||||
const originalCancel = window.cancelAnimationFrame;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = ((id: number) => {
|
||||
callbacks.delete(id);
|
||||
}) as typeof window.cancelAnimationFrame;
|
||||
|
||||
return {
|
||||
flush() {
|
||||
const pending = Array.from(callbacks.entries());
|
||||
callbacks.clear();
|
||||
for (const [, callback] of pending) callback(0);
|
||||
},
|
||||
restore() {
|
||||
window.requestAnimationFrame = originalRequest;
|
||||
window.cancelAnimationFrame = originalCancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setScrollGeometry(
|
||||
element: HTMLElement,
|
||||
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
scrollHeight: { configurable: true, value: geometry.scrollHeight },
|
||||
clientHeight: { configurable: true, value: geometry.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
value: geometry.scrollTop ?? element.scrollTop,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(880);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("follows new reasoning and tool activity while the user is at the bottom", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" with more detail", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "open_browser()",
|
||||
traces: ["open_browser()"],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(1380);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pull the user down after they scroll up inside the activity pane", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
scrollport.scrollTop = 100;
|
||||
fireEvent.scroll(scrollport);
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" still streaming")}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(100);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useTheme", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useTheme", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
useThemeValue: () => "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { ThemeProvider } from "@/hooks/useTheme";
|
||||
|
||||
const mockedStyles = vi.hoisted(() => ({
|
||||
dark: { pre: { background: "#111" } },
|
||||
light: { pre: { background: "#fff" } },
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
|
||||
default: ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style: Record<string, unknown>;
|
||||
}) => (
|
||||
<pre
|
||||
data-testid="highlighted-code"
|
||||
data-theme={style === mockedStyles.dark ? "dark" : "light"}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
|
||||
default: mockedStyles.dark,
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
|
||||
default: mockedStyles.light,
|
||||
}));
|
||||
|
||||
describe("CodeBlock", () => {
|
||||
it("reads theme from context without creating per-block observers", async () => {
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const observer = vi.fn();
|
||||
class MockMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
observer(callback);
|
||||
}
|
||||
|
||||
observe = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("MutationObserver", MockMutationObserver);
|
||||
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"dark",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme="light">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"light",
|
||||
);
|
||||
expect(observer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.stubGlobal("MutationObserver", originalMutationObserver);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
|
||||
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
|
||||
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import {
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
@@ -89,4 +93,37 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
|
||||
{ id: "u2", role: "user", content: "two", createdAt: 5 },
|
||||
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
|
||||
]);
|
||||
|
||||
const flags = assistantCopyFlags(units);
|
||||
const assistantFlags = units
|
||||
.map((unit, index) =>
|
||||
unit.type === "single" && unit.message.role === "assistant"
|
||||
? [unit.message.id, flags[index]]
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import {
|
||||
HISTORY_WINDOW_INCREMENT,
|
||||
INITIAL_HISTORY_WINDOW,
|
||||
ThreadViewport,
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
@@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
||||
|
||||
const emptyMessages: UIMessage[] = [];
|
||||
|
||||
interface ResizeObserverInstance {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
role: "user" as const,
|
||||
content: `message ${index}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||
expect(button).toHaveStyle({ bottom: "192px" });
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
({
|
||||
height: 240,
|
||||
width: 800,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const composerObserver = resizeObservers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(button).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
emptyState={<div>welcome</div>}
|
||||
showScrollToBottomButton={false}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the tail window for long history by default", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("message 140")).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
||||
|
||||
const firstVisible =
|
||||
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
||||
|
||||
expect(
|
||||
screen.queryByText(`message ${firstVisible - 1}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||
const clustered = makeLongMessages(200);
|
||||
clustered.splice(
|
||||
38,
|
||||
3,
|
||||
{
|
||||
id: "r0",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first reasoning",
|
||||
createdAt: 38,
|
||||
},
|
||||
{
|
||||
id: "t0",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 39,
|
||||
},
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second reasoning",
|
||||
createdAt: 40,
|
||||
},
|
||||
);
|
||||
|
||||
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
|
||||
|
||||
expect(visible[0].id).toBe("r0");
|
||||
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
|
||||
});
|
||||
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
|
||||
return {
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "",
|
||||
preview: "First user message",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDeferredTitleRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries refreshing untitled sessions after turn_end", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session(), refresh, [100, 300]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops pending retries once a generated title arrives", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({ activeSession: session({ title: "Generated title" }) });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when the active session already has a title", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears pending retries when the active chat changes", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({
|
||||
activeSession: session({
|
||||
key: "websocket:chat-b",
|
||||
chatId: "chat-b",
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
|
||||
};
|
||||
}
|
||||
|
||||
async function flushStreamFrame() {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("batches answer deltas into one animation-frame update", async () => {
|
||||
const fake = fakeClient();
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: "Hello",
|
||||
});
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: " world",
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
isStreaming: true,
|
||||
});
|
||||
requestFrame.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-flush", {
|
||||
event: "delta",
|
||||
chat_id: "chat-flush",
|
||||
text: "final chunk",
|
||||
});
|
||||
fake.emit("chat-flush", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-flush",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "final chunk",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
|
||||
{
|
||||
wrapper: wrap(fake.client),
|
||||
initialProps: { chatId: "chat-old" },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-old", {
|
||||
event: "delta",
|
||||
chat_id: "chat-old",
|
||||
text: "stale",
|
||||
});
|
||||
});
|
||||
|
||||
rerender({ chatId: "chat-new" });
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-new", {
|
||||
event: "delta",
|
||||
chat_id: "chat-new",
|
||||
text: "fresh",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [{
|
||||
@@ -203,7 +308,7 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -222,6 +327,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("assistant");
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
@@ -328,7 +435,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", () => {
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
@@ -358,6 +465,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||
expect(result.current.messages[2].role).toBe("assistant");
|
||||
@@ -366,7 +475,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach reasoning across a tool trace boundary", () => {
|
||||
it("does not attach reasoning across a tool trace boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -392,6 +501,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||
"message",
|
||||
@@ -651,7 +762,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", () => {
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
||||
@@ -666,6 +777,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
|
||||
Reference in New Issue
Block a user