deer-flow/frontend/tests/unit/core/threads/incremental-stream-state.dom.test.tsx
PeaceMaker-best 6022bdf5ae
perf(frontend): avoid redundant chat state snapshots (#5159)
* perf(frontend): avoid redundant chat state snapshots

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* fix(streaming): preserve incremental chat semantics

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
2026-09-04 08:20:12 +08:00

146 lines
3.9 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import { expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import { DEFAULT_LOCAL_SETTINGS } from "@/core/settings/local";
import type { AgentThreadState, GoalState } from "@/core/threads/types";
type StreamOptions = {
onUpdateEvent?: (
data: unknown,
options: {
mutate: (
update:
| Partial<AgentThreadState>
| ((previous: AgentThreadState) => Partial<AgentThreadState>),
) => void;
},
) => void;
};
const streamMockState = rs.hoisted(() => {
const existingMessage = {
type: "ai",
id: "message-1",
content: "Already streamed",
} as Message;
return {
existingMessage,
options: undefined as StreamOptions | undefined,
values: {
title: "Before",
messages: [existingMessage],
artifacts: ["old.md"],
todos: [{ content: "old", status: "pending" as const }],
goal: null,
} as AgentThreadState,
};
});
const existingMessage = streamMockState.existingMessage;
rs.mock("@langchain/langgraph-sdk/react", () => ({
useStream: (options: StreamOptions) => {
streamMockState.options = options;
return {
isLoading: true,
messages: streamMockState.values.messages,
stop: async () => undefined,
submit: async () => undefined,
values: streamMockState.values,
};
},
}));
test("updates rendered thread state without receiving a values frame", async () => {
const { useThreadStream } = await import("@/core/threads/hooks");
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
I18nContext.Provider,
{
value: {
locale: "en-US",
setLocale: () => undefined,
t: enUS,
},
},
children,
),
);
const { rerender, result } = renderHook(
() =>
useThreadStream({
context: DEFAULT_LOCAL_SETTINGS.context,
isMock: true,
threadId: "thread-1",
}),
{ wrapper },
);
const goal = {
objective: "Finish the report",
status: "active",
created_at: "2026-09-02T00:00:00Z",
updated_at: "2026-09-02T00:01:00Z",
continuation_count: 1,
max_continuations: 8,
no_progress_count: 0,
} as GoalState;
const todos = [{ content: "Draft", status: "in_progress" as const }];
act(() => {
streamMockState.options?.onUpdateEvent?.(
{ agent: { messages: [existingMessage], summary_text: "internal" } },
{
mutate() {
throw new Error("irrelevant updates must not mutate thread values");
},
},
);
});
act(() => {
streamMockState.options?.onUpdateEvent?.(
{
agent: {
artifacts: ["report.md"],
goal,
// The same message also arrives through messages-tuple. The update
// path must not apply it a second time.
messages: [existingMessage],
title: "After",
todos,
},
},
{
mutate(update) {
const patch =
typeof update === "function"
? update(streamMockState.values)
: update;
streamMockState.values = { ...streamMockState.values, ...patch };
},
},
);
rerender();
});
expect(result.current.thread.values).toMatchObject({
artifacts: ["old.md", "report.md"],
goal,
title: "After",
todos,
});
expect(result.current.thread.messages).toEqual([existingMessage]);
});