mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): keep renamed thread titles in sync (#5045)
* fix(frontend): 同步会话重命名后的标题状态 * chore: 重新触发 PR 自动分流检查 * fix(frontend): address thread title sync review feedback Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fall back when canonical thread title is empty Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fence stale metadata after thread rename Signed-off-by: 橘猫 <2622045569@qq.com> * fix(frontend): fence stale thread list responses after rename Signed-off-by: 橘猫 <2622045569@qq.com> --------- Signed-off-by: 橘猫 <2622045569@qq.com>
This commit is contained in:
parent
ddd9aec558
commit
91c7ed4cf5
@ -275,7 +275,11 @@ export default function AgentChatPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
<ThreadTitle
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
canonicalTitle={threadMetadata.data?.values?.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center sm:mr-4">
|
||||
{!isNewThread &&
|
||||
|
||||
@ -288,7 +288,11 @@ export default function ChatPage() {
|
||||
>
|
||||
{!isMock && <SidebarTrigger className="md:hidden" />}
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
<ThreadTitle
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
canonicalTitle={threadMetadata.data?.values?.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isNewThread &&
|
||||
|
||||
@ -7,21 +7,27 @@ import type { AgentThreadState } from "@/core/threads";
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
|
||||
export function ThreadTitle({
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
export type ThreadTitleProps = {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
canonicalTitle?: string;
|
||||
};
|
||||
|
||||
export function ThreadTitle({
|
||||
threadId,
|
||||
thread,
|
||||
canonicalTitle,
|
||||
}: ThreadTitleProps) {
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const title = canonicalTitle?.length ? canonicalTitle : thread.values?.title;
|
||||
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = thread.values.title;
|
||||
if (title) {
|
||||
_title = title;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
@ -36,15 +42,11 @@ export function ThreadTitle({
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
title,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{thread.values.title ?? "Untitled"}
|
||||
</FlipDisplay>
|
||||
);
|
||||
return <FlipDisplay uniqueKey={threadId}>{title}</FlipDisplay>;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import {
|
||||
type QueryClient,
|
||||
type InfiniteData,
|
||||
type QueryFilters,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@ -1809,47 +1810,10 @@ export function useThreadStream({
|
||||
);
|
||||
for (const update of updates) {
|
||||
if (update && "title" in update && update.title) {
|
||||
void queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
return oldData?.map((t) => {
|
||||
if (t.thread_id === threadIdRef.current) {
|
||||
return {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title: update.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
},
|
||||
);
|
||||
const nextTitle: string = update.title;
|
||||
void queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
mapInfiniteThreadsCache(
|
||||
oldData,
|
||||
(t): AgentThread =>
|
||||
t.thread_id === threadIdRef.current
|
||||
? {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title: nextTitle,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const currentThreadId = threadIdRef.current;
|
||||
if (currentThreadId) {
|
||||
setThreadTitleInCaches(queryClient, currentThreadId, update.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -2922,46 +2886,82 @@ function mergeThreadMetadata(
|
||||
};
|
||||
}
|
||||
|
||||
function setThreadMetadataInCaches(
|
||||
queryClient: QueryClient,
|
||||
threadId: string,
|
||||
metadata: ThreadMetadataPatch,
|
||||
) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
function mergeThreadTitle(thread: AgentThread, title: string): AgentThread {
|
||||
return {
|
||||
...thread,
|
||||
values: {
|
||||
...thread.values,
|
||||
title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getThreadSnapshotCacheFilters(threadId: string) {
|
||||
return {
|
||||
search: {
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
infiniteSearch: {
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
metadata: {
|
||||
queryKey: ["thread", "metadata", threadId],
|
||||
exact: false,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function setThreadInCaches(
|
||||
queryClient: QueryClient,
|
||||
threadId: string,
|
||||
mapper: (thread: AgentThread) => AgentThread,
|
||||
): void {
|
||||
const filters = getThreadSnapshotCacheFilters(threadId);
|
||||
|
||||
queryClient.setQueriesData(
|
||||
filters.search,
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
return oldData.map((thread) =>
|
||||
thread.thread_id === threadId
|
||||
? mergeThreadMetadata(thread, metadata)
|
||||
: thread,
|
||||
thread.thread_id === threadId ? mapper(thread) : thread,
|
||||
);
|
||||
},
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
filters.infiniteSearch,
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
mapInfiniteThreadsCache(oldData, (thread) =>
|
||||
thread.thread_id === threadId
|
||||
? mergeThreadMetadata(thread, metadata)
|
||||
: thread,
|
||||
thread.thread_id === threadId ? mapper(thread) : thread,
|
||||
),
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["thread", "metadata", threadId],
|
||||
exact: false,
|
||||
},
|
||||
filters.metadata,
|
||||
(oldData: AgentThread | null | undefined) =>
|
||||
oldData ? mergeThreadMetadata(oldData, metadata) : oldData,
|
||||
oldData ? mapper(oldData) : oldData,
|
||||
);
|
||||
}
|
||||
|
||||
function setThreadMetadataInCaches(
|
||||
queryClient: QueryClient,
|
||||
threadId: string,
|
||||
metadata: ThreadMetadataPatch,
|
||||
) {
|
||||
setThreadInCaches(queryClient, threadId, (thread) =>
|
||||
mergeThreadMetadata(thread, metadata),
|
||||
);
|
||||
}
|
||||
|
||||
export function setThreadTitleInCaches(
|
||||
queryClient: QueryClient,
|
||||
threadId: string,
|
||||
title: string,
|
||||
): void {
|
||||
setThreadInCaches(queryClient, threadId, (thread) =>
|
||||
mergeThreadTitle(thread, title),
|
||||
);
|
||||
}
|
||||
|
||||
@ -3320,45 +3320,19 @@ export function useRenameThread() {
|
||||
values: { title },
|
||||
});
|
||||
},
|
||||
onSuccess(_, { threadId, title }) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread>) => {
|
||||
return oldData.map((t) => {
|
||||
if (t.thread_id === threadId) {
|
||||
return {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title,
|
||||
},
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
},
|
||||
async onSuccess(_, { threadId, title }) {
|
||||
const filters: QueryFilters[] = Object.values(
|
||||
getThreadSnapshotCacheFilters(threadId),
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
mapInfiniteThreadsCache(oldData, (t) =>
|
||||
t.thread_id === threadId
|
||||
? {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
),
|
||||
|
||||
// Prevent pre-rename snapshot requests from restoring the stale title.
|
||||
await Promise.all(
|
||||
filters.map((filter) => queryClient.cancelQueries(filter)),
|
||||
);
|
||||
setThreadTitleInCaches(queryClient, threadId, title);
|
||||
for (const filter of filters) {
|
||||
void queryClient.invalidateQueries(filter);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
188
frontend/tests/e2e/thread-title-sync.spec.ts
Normal file
188
frontend/tests/e2e/thread-title-sync.spec.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const THREAD_ID = "00000000-0000-0000-0000-000000000321";
|
||||
const ORIGINAL_TITLE = "Original title";
|
||||
const RENAMED_TITLE = "Renamed title";
|
||||
|
||||
test("renaming a thread updates the sidebar, header, and document title", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: THREAD_ID,
|
||||
title: ORIGINAL_TITLE,
|
||||
updated_at: "2026-07-05T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
||||
await expect(page.getByText(ORIGINAL_TITLE).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page).toHaveTitle(`${ORIGINAL_TITLE} - DeerFlow`);
|
||||
|
||||
const threadItem = page
|
||||
.locator(
|
||||
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
|
||||
)
|
||||
.locator("xpath=..");
|
||||
await threadItem.hover();
|
||||
await threadItem.getByRole("button", { name: "More" }).click();
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill(RENAMED_TITLE);
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(threadItem).toContainText(RENAMED_TITLE);
|
||||
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
|
||||
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
|
||||
|
||||
await page.reload();
|
||||
await expect(threadItem).toContainText(RENAMED_TITLE);
|
||||
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
|
||||
});
|
||||
|
||||
test("a stale metadata response cannot restore the old title after rename", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: THREAD_ID,
|
||||
title: ORIGINAL_TITLE,
|
||||
updated_at: "2026-07-05T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let releaseStaleMetadataResponse!: () => void;
|
||||
const staleMetadataResponseGate = new Promise<void>((resolve) => {
|
||||
releaseStaleMetadataResponse = resolve;
|
||||
});
|
||||
let markStaleMetadataRequestStarted!: () => void;
|
||||
const staleMetadataRequestStarted = new Promise<void>((resolve) => {
|
||||
markStaleMetadataRequestStarted = resolve;
|
||||
});
|
||||
let markStaleMetadataResponseCompleted!: () => void;
|
||||
const staleMetadataResponseCompleted = new Promise<void>((resolve) => {
|
||||
markStaleMetadataResponseCompleted = resolve;
|
||||
});
|
||||
let releaseFreshMetadataResponse!: () => void;
|
||||
const freshMetadataResponseGate = new Promise<void>((resolve) => {
|
||||
releaseFreshMetadataResponse = resolve;
|
||||
});
|
||||
let markFreshMetadataRequestStarted!: () => void;
|
||||
const freshMetadataRequestStarted = new Promise<void>((resolve) => {
|
||||
markFreshMetadataRequestStarted = resolve;
|
||||
});
|
||||
let markFreshMetadataResponseCompleted!: () => void;
|
||||
const freshMetadataResponseCompleted = new Promise<void>((resolve) => {
|
||||
markFreshMetadataResponseCompleted = resolve;
|
||||
});
|
||||
let delayMetadataResponses = false;
|
||||
let metadataRequestCount = 0;
|
||||
|
||||
await page.route("**/api/langgraph/threads/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (
|
||||
route.request().method() !== "GET" ||
|
||||
url.pathname !== `/api/langgraph/threads/${THREAD_ID}`
|
||||
) {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
if (!delayMetadataResponses) {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
metadataRequestCount += 1;
|
||||
if (metadataRequestCount > 1) {
|
||||
markFreshMetadataRequestStarted();
|
||||
await freshMetadataResponseGate;
|
||||
await route.fallback();
|
||||
markFreshMetadataResponseCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
markStaleMetadataRequestStarted();
|
||||
await staleMetadataResponseGate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
thread_id: THREAD_ID,
|
||||
created_at: "2026-07-05T10:00:00Z",
|
||||
updated_at: "2026-07-05T10:00:00Z",
|
||||
metadata: {},
|
||||
status: "idle",
|
||||
values: { title: ORIGINAL_TITLE, goal: null },
|
||||
}),
|
||||
});
|
||||
markStaleMetadataResponseCompleted();
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
||||
await expect(page.locator("header").getByText(ORIGINAL_TITLE)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
delayMetadataResponses = true;
|
||||
await page.getByRole("link", { name: "Chats", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/workspace\/chats$/);
|
||||
await page
|
||||
.locator(
|
||||
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
|
||||
)
|
||||
.click();
|
||||
await staleMetadataRequestStarted;
|
||||
|
||||
const threadItem = page
|
||||
.locator(
|
||||
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
|
||||
)
|
||||
.locator("xpath=..");
|
||||
await expect(threadItem).toContainText(ORIGINAL_TITLE);
|
||||
await threadItem.hover();
|
||||
await threadItem.getByRole("button", { name: "More" }).click();
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill(RENAMED_TITLE);
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(threadItem).toContainText(RENAMED_TITLE);
|
||||
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
|
||||
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
|
||||
|
||||
releaseStaleMetadataResponse();
|
||||
await staleMetadataResponseCompleted;
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
|
||||
// The stale response has completed while the fresh refetch is still blocked.
|
||||
// The title must remain renamed without relying on the refetch to repair it.
|
||||
await expect(threadItem).toContainText(RENAMED_TITLE);
|
||||
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
|
||||
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
|
||||
|
||||
await freshMetadataRequestStarted;
|
||||
releaseFreshMetadataResponse();
|
||||
await freshMetadataResponseCompleted;
|
||||
|
||||
await expect(threadItem).toContainText(RENAMED_TITLE);
|
||||
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
|
||||
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
|
||||
});
|
||||
@ -375,6 +375,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return updated;
|
||||
};
|
||||
|
||||
const patchThreadTitle = (threadId: string, title: string) => {
|
||||
let updated: MockThread | undefined;
|
||||
threads = threads.map((thread) => {
|
||||
if (thread.thread_id !== threadId) {
|
||||
return thread;
|
||||
}
|
||||
updated = { ...thread, title };
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
};
|
||||
|
||||
// Auth — keep workspace tests independent from a real gateway session.
|
||||
void page.route("**/api/v1/auth/me", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
@ -1054,9 +1066,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
|
||||
// Thread state — getState for individual thread
|
||||
void page.route("**/api/langgraph/threads/*/state", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const threadId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
|
||||
const matchingThread = threads.find(
|
||||
(thread) => thread.thread_id === threadId,
|
||||
);
|
||||
|
||||
if (route.request().method() === "GET") {
|
||||
const url = route.request().url();
|
||||
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -1086,6 +1102,33 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
values?: { title?: unknown };
|
||||
};
|
||||
const updated =
|
||||
typeof body.values?.title === "string"
|
||||
? patchThreadTitle(threadId, body.values.title)
|
||||
: matchingThread;
|
||||
if (!updated) {
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Thread not found" }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
checkpoint_ns: "",
|
||||
checkpoint_id: "mock-checkpoint",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk";
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
pages: {
|
||||
appName: "DeerFlow",
|
||||
newChat: "New chat",
|
||||
untitled: "Untitled",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
rs.mock("@/components/workspace/chats", () => ({
|
||||
useThreadChat: () => ({ isNewThread: false }),
|
||||
}));
|
||||
|
||||
rs.mock("@/components/workspace/flip-display", () => ({
|
||||
FlipDisplay: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
function makeThread(title: string): BaseStream<AgentThreadState> {
|
||||
return {
|
||||
isThreadLoading: false,
|
||||
values: { title },
|
||||
} as unknown as BaseStream<AgentThreadState>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.title = "";
|
||||
});
|
||||
|
||||
test("prefers the canonical metadata title over a stale stream title", () => {
|
||||
const { container } = render(
|
||||
<ThreadTitle
|
||||
threadId="thread-1"
|
||||
thread={makeThread("Stream title")}
|
||||
canonicalTitle="Renamed title"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe("Renamed title");
|
||||
expect(document.title).toBe("Renamed title - DeerFlow");
|
||||
});
|
||||
|
||||
test("falls back to the stream title when metadata is unavailable", () => {
|
||||
const { container } = render(
|
||||
<ThreadTitle threadId="thread-1" thread={makeThread("Stream title")} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe("Stream title");
|
||||
expect(document.title).toBe("Stream title - DeerFlow");
|
||||
});
|
||||
|
||||
test("falls back to the stream title when metadata title is empty", () => {
|
||||
const { container } = render(
|
||||
<ThreadTitle
|
||||
threadId="thread-1"
|
||||
thread={makeThread("Stream title")}
|
||||
canonicalTitle=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe("Stream title");
|
||||
expect(document.title).toBe("Stream title - DeerFlow");
|
||||
});
|
||||
170
frontend/tests/unit/core/threads/rename-thread.dom.test.tsx
Normal file
170
frontend/tests/unit/core/threads/rename-thread.dom.test.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
type InfiniteData,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
const apiMocks = rs.hoisted(() => ({
|
||||
updateState: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/api", () => ({
|
||||
getAPIClient: () => ({
|
||||
threads: {
|
||||
updateState: apiMocks.updateState,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
useRenameThread,
|
||||
} from "@/core/threads/hooks";
|
||||
import { DEFAULT_THREAD_SEARCH_PARAMS } from "@/core/threads/thread-search-query";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
const THREAD_ID = "thread-1";
|
||||
const ORIGINAL_TITLE = "Original title";
|
||||
const RENAMED_TITLE = "Renamed title";
|
||||
|
||||
function makeThread(title: string): AgentThread {
|
||||
return {
|
||||
thread_id: THREAD_ID,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
metadata: {},
|
||||
status: "idle",
|
||||
values: { title },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
function createDelayedResult<T>(value: T) {
|
||||
let markStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let markCompleted!: () => void;
|
||||
const completed = new Promise<void>((resolve) => {
|
||||
markCompleted = resolve;
|
||||
});
|
||||
|
||||
return {
|
||||
completed,
|
||||
queryFn: async () => {
|
||||
markStarted();
|
||||
await gate;
|
||||
markCompleted();
|
||||
return value;
|
||||
},
|
||||
release,
|
||||
started,
|
||||
};
|
||||
}
|
||||
|
||||
function readSearchTitle(
|
||||
queryClient: QueryClient,
|
||||
queryKey: readonly unknown[],
|
||||
) {
|
||||
return queryClient.getQueryData<AgentThread[]>(queryKey)?.[0]?.values.title;
|
||||
}
|
||||
|
||||
function readInfiniteSearchTitle(
|
||||
queryClient: QueryClient,
|
||||
queryKey: readonly unknown[],
|
||||
) {
|
||||
return queryClient.getQueryData<InfiniteData<AgentThread[]>>(queryKey)
|
||||
?.pages[0]?.[0]?.values.title;
|
||||
}
|
||||
|
||||
test("stale list responses cannot restore the old title after rename", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const infiniteParams = {
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
};
|
||||
const searchKey = [
|
||||
"threads",
|
||||
"search",
|
||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
] as const;
|
||||
const infiniteSearchKey = [
|
||||
...INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
infiniteParams,
|
||||
] as const;
|
||||
const originalThread = makeThread(ORIGINAL_TITLE);
|
||||
const staleSearch = createDelayedResult([originalThread]);
|
||||
const staleInfiniteSearch = createDelayedResult([originalThread]);
|
||||
|
||||
queryClient.setQueryData(searchKey, [originalThread]);
|
||||
queryClient.setQueryData<InfiniteData<AgentThread[]>>(infiniteSearchKey, {
|
||||
pages: [[originalThread]],
|
||||
pageParams: [0],
|
||||
});
|
||||
|
||||
const searchFetch = queryClient
|
||||
.fetchQuery({
|
||||
queryKey: searchKey,
|
||||
queryFn: staleSearch.queryFn,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const infiniteSearchFetch = queryClient
|
||||
.fetchInfiniteQuery({
|
||||
queryKey: infiniteSearchKey,
|
||||
initialPageParam: 0,
|
||||
queryFn: staleInfiniteSearch.queryFn,
|
||||
getNextPageParam: () => undefined,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
await Promise.all([staleSearch.started, staleInfiniteSearch.started]);
|
||||
apiMocks.updateState.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useRenameThread(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
threadId: THREAD_ID,
|
||||
title: RENAMED_TITLE,
|
||||
});
|
||||
});
|
||||
|
||||
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
|
||||
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
|
||||
RENAMED_TITLE,
|
||||
);
|
||||
|
||||
staleSearch.release();
|
||||
staleInfiniteSearch.release();
|
||||
await Promise.all([staleSearch.completed, staleInfiniteSearch.completed]);
|
||||
await Promise.all([searchFetch, infiniteSearchFetch]);
|
||||
|
||||
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
|
||||
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
|
||||
RENAMED_TITLE,
|
||||
);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.updateState.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user