mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(frontend): keep the thread list offset past hidden side chats
Primary thread lists hide sidecar threads on the client, so a page of 50
visible chats can consume more server rows. fetchInfiniteThreadsPage kept
that server offset in a Symbol property on the page array, but TanStack
Query's structural sharing (replaceEqualDeep) rebuilds a changed array
without symbol keys, and the list cache writers (rename, unread, pin,
delete, upsert) rebuild pages with map/filter. After the first refetch or
cache write, getInfiniteThreadsNextPageParam fell back to the sum of the
visible page lengths, so the next page started at 50 instead of 53 and
re-fetched rows already loaded. The same fallback treated a full page
that lost a row to a local delete as the end of the list.
Each cached page is now { threads, hiddenCount, hasMore }. The next offset
is the sum of visible and hidden rows over the loaded pages, so local
deletes and inserts still move it with the server list, and the end of the
list comes from the server response rather than the page length.
useInfiniteThreads projects the pages back to thread arrays with select,
so list components are unchanged.
* test(frontend): pin the sidebar's next-page offset past side chats in E2E
Three side chats in the first 50 server rows push the sidebar's second
page to offset 53. After a rename through the sidebar menu, scrolling to
the sentinel must request offset 53, not 50. On main this test fails with
the request at offset 50.
* docs(changelog): reference #6702
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
import { describe, expect, rs, test } from "@rstest/core";
|
|
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
|
|
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
|
|
import {
|
|
fetchInfiniteThreadsPage,
|
|
filterInfiniteThreadsCache,
|
|
getInfiniteThreadsNextPageParam,
|
|
INFINITE_THREADS_PAGE_SIZE,
|
|
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
|
type InfiniteThreadsData,
|
|
type InfiniteThreadsPage,
|
|
invalidateStoppedThreadCaches,
|
|
mapInfiniteThreadsCache,
|
|
STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS,
|
|
stopThreadAndInvalidateCaches,
|
|
upsertThreadInInfiniteCache,
|
|
} from "@/core/threads/hooks";
|
|
import type { AgentThread } from "@/core/threads/types";
|
|
|
|
// Issue #3482: the sidebar and /workspace/chats list used to be capped at
|
|
// 50 threads because `useThreads()` exits as soon as `threads.length >=
|
|
// params.limit`. These pure helpers back the `useInfiniteThreads()`
|
|
// pagination logic and the mirrored cache writes that keep rename / delete
|
|
// / stream-finish in sync with both the legacy array cache and the new
|
|
// infinite cache.
|
|
|
|
function makeThread(
|
|
id: string,
|
|
title = `Title ${id}`,
|
|
metadata: Record<string, unknown> = {},
|
|
): AgentThread {
|
|
return {
|
|
thread_id: id,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
metadata,
|
|
status: "idle",
|
|
values: { title },
|
|
} as unknown as AgentThread;
|
|
}
|
|
|
|
function makePage(
|
|
start: number,
|
|
size: number,
|
|
{
|
|
hiddenCount = 0,
|
|
hasMore = size === INFINITE_THREADS_PAGE_SIZE,
|
|
}: { hiddenCount?: number; hasMore?: boolean } = {},
|
|
): InfiniteThreadsPage {
|
|
return {
|
|
threads: Array.from({ length: size }, (_, i) =>
|
|
makeThread(`t-${start + i}`),
|
|
),
|
|
hiddenCount,
|
|
hasMore,
|
|
};
|
|
}
|
|
|
|
function makeInfiniteData(pages: AgentThread[][]): InfiniteThreadsData {
|
|
return {
|
|
pages: pages.map((threads) => ({ threads, hiddenCount: 0, hasMore: true })),
|
|
pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE),
|
|
};
|
|
}
|
|
|
|
describe("getInfiniteThreadsNextPageParam", () => {
|
|
test("returns next offset when the last page has more rows", () => {
|
|
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
|
expect(getInfiniteThreadsNextPageParam(page1, [page1])).toBe(
|
|
INFINITE_THREADS_PAGE_SIZE,
|
|
);
|
|
});
|
|
|
|
test("returns next offset across multiple full pages", () => {
|
|
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
|
const page2 = makePage(
|
|
INFINITE_THREADS_PAGE_SIZE,
|
|
INFINITE_THREADS_PAGE_SIZE,
|
|
);
|
|
expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe(
|
|
INFINITE_THREADS_PAGE_SIZE * 2,
|
|
);
|
|
});
|
|
|
|
test("advances past server rows that pages hid", () => {
|
|
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 });
|
|
const page2 = makePage(53, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 1 });
|
|
expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe(
|
|
INFINITE_THREADS_PAGE_SIZE * 2 + 4,
|
|
);
|
|
});
|
|
|
|
test("returns undefined when the last page reached the end of the list", () => {
|
|
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
|
const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 10);
|
|
expect(
|
|
getInfiniteThreadsNextPageParam(page2, [page1, page2]),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined when the last page is empty", () => {
|
|
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
|
const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 0);
|
|
expect(
|
|
getInfiniteThreadsNextPageParam(page2, [page1, page2]),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("reads the end of the list from the page, not its length", () => {
|
|
const shrunkByDelete = makePage(0, INFINITE_THREADS_PAGE_SIZE - 1, {
|
|
hasMore: true,
|
|
});
|
|
expect(
|
|
getInfiniteThreadsNextPageParam(shrunkByDelete, [shrunkByDelete]),
|
|
).toBe(INFINITE_THREADS_PAGE_SIZE - 1);
|
|
|
|
const grownByInsert = makePage(0, INFINITE_THREADS_PAGE_SIZE, {
|
|
hasMore: false,
|
|
});
|
|
expect(
|
|
getInfiniteThreadsNextPageParam(grownByInsert, [grownByInsert]),
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("fetchInfiniteThreadsPage", () => {
|
|
test("fills a visible page while advancing offsets by raw backend rows", async () => {
|
|
const search = rs
|
|
.fn()
|
|
.mockResolvedValueOnce([
|
|
makeThread("sidecar-1", "Sidecar", { deerflow_sidecar: true }),
|
|
makeThread("primary-1"),
|
|
])
|
|
.mockResolvedValueOnce([makeThread("primary-2")]);
|
|
|
|
const page = await fetchInfiniteThreadsPage(
|
|
{ threads: { search } },
|
|
{ sortBy: "updated_at", sortOrder: "desc" },
|
|
0,
|
|
2,
|
|
);
|
|
|
|
expect(page.threads.map((thread) => thread.thread_id)).toEqual([
|
|
"primary-1",
|
|
"primary-2",
|
|
]);
|
|
expect(search).toHaveBeenNthCalledWith(1, {
|
|
sortBy: "updated_at",
|
|
sortOrder: "desc",
|
|
limit: 2,
|
|
offset: 0,
|
|
});
|
|
expect(search).toHaveBeenNthCalledWith(2, {
|
|
sortBy: "updated_at",
|
|
sortOrder: "desc",
|
|
limit: 1,
|
|
offset: 2,
|
|
});
|
|
expect(page).toMatchObject({ hiddenCount: 1, hasMore: true });
|
|
expect(getInfiniteThreadsNextPageParam(page, [page])).toBe(3);
|
|
});
|
|
|
|
test("keeps sidecar rows when the caller explicitly searches for sidecars", async () => {
|
|
const search = rs.fn().mockResolvedValueOnce([
|
|
makeThread("sidecar-1", "Sidecar", {
|
|
deerflow_sidecar: true,
|
|
parent_thread_id: "parent-1",
|
|
}),
|
|
]);
|
|
|
|
const page = await fetchInfiniteThreadsPage(
|
|
{ threads: { search } },
|
|
{
|
|
sortBy: "updated_at",
|
|
sortOrder: "desc",
|
|
metadata: { deerflow_sidecar: true, parent_thread_id: "parent-1" },
|
|
},
|
|
0,
|
|
2,
|
|
);
|
|
|
|
expect(page.threads.map((thread) => thread.thread_id)).toEqual([
|
|
"sidecar-1",
|
|
]);
|
|
expect(page).toMatchObject({ hiddenCount: 0, hasMore: false });
|
|
expect(getInfiniteThreadsNextPageParam(page, [page])).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("mapInfiniteThreadsCache", () => {
|
|
test("returns undefined when oldData is undefined", () => {
|
|
expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined();
|
|
});
|
|
|
|
test("updates the matching thread across multiple pages", () => {
|
|
const page1 = [makeThread("a"), makeThread("b")];
|
|
const page2 = [makeThread("c"), makeThread("d")];
|
|
const data = makeInfiniteData([page1, page2]);
|
|
|
|
const updated = mapInfiniteThreadsCache(data, (t) =>
|
|
t.thread_id === "c"
|
|
? { ...t, values: { ...t.values, title: "renamed" } }
|
|
: t,
|
|
);
|
|
|
|
expect(updated?.pages[0]?.threads[0]?.values?.title).toBe("Title a");
|
|
expect(updated?.pages[1]?.threads[0]?.thread_id).toBe("c");
|
|
expect(updated?.pages[1]?.threads[0]?.values?.title).toBe("renamed");
|
|
expect(updated?.pages[1]?.threads[1]?.values?.title).toBe("Title d");
|
|
});
|
|
|
|
test("preserves pageParams and the pagination state of each page", () => {
|
|
const data: InfiniteThreadsData = {
|
|
pages: [makePage(0, 2, { hiddenCount: 3, hasMore: false })],
|
|
pageParams: [0],
|
|
};
|
|
const updated = mapInfiniteThreadsCache(data, (t) => t);
|
|
expect(updated?.pageParams).toEqual(data.pageParams);
|
|
expect(updated?.pages[0]).toMatchObject({ hiddenCount: 3, hasMore: false });
|
|
});
|
|
});
|
|
|
|
describe("filterInfiniteThreadsCache", () => {
|
|
test("returns undefined when oldData is undefined", () => {
|
|
expect(filterInfiniteThreadsCache(undefined, () => true)).toBeUndefined();
|
|
});
|
|
|
|
test("removes matching threads across all pages", () => {
|
|
const page1 = [makeThread("a"), makeThread("b")];
|
|
const page2 = [makeThread("b"), makeThread("c")];
|
|
const data = makeInfiniteData([page1, page2]);
|
|
|
|
const filtered = filterInfiniteThreadsCache(
|
|
data,
|
|
(t) => t.thread_id !== "b",
|
|
);
|
|
|
|
expect(filtered?.pages[0]?.threads.map((t) => t.thread_id)).toEqual(["a"]);
|
|
expect(filtered?.pages[1]?.threads.map((t) => t.thread_id)).toEqual(["c"]);
|
|
});
|
|
|
|
test("keeps an emptied page as an empty array (does not drop the page)", () => {
|
|
const page1 = [makeThread("a")];
|
|
const page2 = [makeThread("b")];
|
|
const data = makeInfiniteData([page1, page2]);
|
|
|
|
const filtered = filterInfiniteThreadsCache(
|
|
data,
|
|
(t) => t.thread_id !== "a",
|
|
);
|
|
|
|
expect(filtered?.pages).toHaveLength(2);
|
|
expect(filtered?.pages[0]?.threads).toEqual([]);
|
|
expect(filtered?.pages[1]?.threads[0]?.thread_id).toBe("b");
|
|
});
|
|
|
|
test("moves the next offset back with a delete and keeps hidden rows", () => {
|
|
const data: InfiniteThreadsData = {
|
|
pages: [
|
|
makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 }),
|
|
makePage(53, INFINITE_THREADS_PAGE_SIZE),
|
|
],
|
|
pageParams: [0, 53],
|
|
};
|
|
|
|
// The deleted thread also leaves the server list, so the next server
|
|
// offset moves back by one; the hidden sidecar rows still count.
|
|
const filtered = filterInfiniteThreadsCache(
|
|
data,
|
|
(t) => t.thread_id !== "t-0",
|
|
);
|
|
const pages = filtered?.pages ?? [];
|
|
expect(getInfiniteThreadsNextPageParam(pages.at(-1)!, pages)).toBe(102);
|
|
});
|
|
});
|
|
|
|
describe("upsertThreadInInfiniteCache", () => {
|
|
function seedClient(initial?: InfiniteThreadsData): QueryClient {
|
|
const client = new QueryClient();
|
|
if (initial) {
|
|
client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
function readCache(client: QueryClient): InfiniteThreadsData | undefined {
|
|
return client.getQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}]);
|
|
}
|
|
|
|
test("no-op when the infinite cache has not been initialised yet", () => {
|
|
const client = seedClient();
|
|
upsertThreadInInfiniteCache(client, makeThread("new"));
|
|
expect(readCache(client)).toBeUndefined();
|
|
});
|
|
|
|
test("prepends a brand-new thread to the first page", () => {
|
|
const client = seedClient(
|
|
makeInfiniteData([[makeThread("a"), makeThread("b")]]),
|
|
);
|
|
upsertThreadInInfiniteCache(client, makeThread("new"));
|
|
const cache = readCache(client);
|
|
expect(cache?.pages[0]?.threads.map((t) => t.thread_id)).toEqual([
|
|
"new",
|
|
"a",
|
|
"b",
|
|
]);
|
|
});
|
|
|
|
test("merges into the existing entry instead of duplicating it", () => {
|
|
const existing = makeThread("a", "Old title");
|
|
const client = seedClient(makeInfiniteData([[existing, makeThread("b")]]));
|
|
// Simulate an onCreated upsert that races with a thread already in cache:
|
|
// the cache copy should win for title/metadata (it represents later state),
|
|
// but no duplicate row should appear.
|
|
upsertThreadInInfiniteCache(client, {
|
|
...makeThread("a", "New title"),
|
|
status: "busy",
|
|
});
|
|
const cache = readCache(client);
|
|
const ids = cache?.pages[0]?.threads.map((t) => t.thread_id);
|
|
expect(ids).toEqual(["a", "b"]);
|
|
expect(cache?.pages[0]?.threads[0]?.values.title).toBe("Old title");
|
|
});
|
|
});
|
|
|
|
describe("invalidateStoppedThreadCaches", () => {
|
|
function invalidatedQueryKeys(client: QueryClient) {
|
|
const invalidate = rs.spyOn(client, "invalidateQueries");
|
|
return {
|
|
invalidate,
|
|
queryKeys: () =>
|
|
invalidate.mock.calls.map(([filters]) => filters?.queryKey),
|
|
};
|
|
}
|
|
|
|
test("refreshes current thread and sidebar caches after fire-and-forget stop", () => {
|
|
const client = new QueryClient();
|
|
const { queryKeys } = invalidatedQueryKeys(client);
|
|
|
|
invalidateStoppedThreadCaches(client, "thread-1", false);
|
|
|
|
expect(queryKeys()).toContainEqual(["threads", "search"]);
|
|
expect(queryKeys()).toContainEqual(INFINITE_THREADS_QUERY_KEY_PREFIX);
|
|
expect(queryKeys()).toContainEqual(["thread", "thread-1"]);
|
|
expect(queryKeys()).toContainEqual([
|
|
"thread",
|
|
"metadata",
|
|
"thread-1",
|
|
false,
|
|
]);
|
|
expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
|
|
});
|
|
|
|
test("preserves loaded history pages while invalidating", () => {
|
|
const client = new QueryClient();
|
|
const key = ["thread-messages", "thread-1"] as const;
|
|
const latest = { data: [], has_more: true, next_before_seq: 20 };
|
|
const older = { data: [], has_more: false, next_before_seq: null };
|
|
client.setQueryData(key, {
|
|
pages: [latest, older],
|
|
pageParams: [null, 20],
|
|
});
|
|
|
|
invalidateStoppedThreadCaches(client, "thread-1", false);
|
|
|
|
expect(client.getQueryData(key)).toEqual({
|
|
pages: [latest, older],
|
|
pageParams: [null, 20],
|
|
});
|
|
});
|
|
|
|
test("does not refresh per-thread API caches for mock threads", () => {
|
|
const client = new QueryClient();
|
|
const { queryKeys } = invalidatedQueryKeys(client);
|
|
|
|
invalidateStoppedThreadCaches(client, "thread-1", true);
|
|
|
|
expect(queryKeys()).toContainEqual(["threads", "search"]);
|
|
expect(queryKeys()).toContainEqual(INFINITE_THREADS_QUERY_KEY_PREFIX);
|
|
expect(queryKeys()).not.toContainEqual(["thread", "thread-1"]);
|
|
expect(queryKeys()).not.toContainEqual([
|
|
"thread",
|
|
"metadata",
|
|
"thread-1",
|
|
true,
|
|
]);
|
|
expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
|
|
});
|
|
|
|
test("wraps SDK stop and refreshes caches after it resolves", async () => {
|
|
const client = new QueryClient();
|
|
const stop = rs.fn(() => Promise.resolve());
|
|
const { queryKeys } = invalidatedQueryKeys(client);
|
|
|
|
await stopThreadAndInvalidateCaches(client, stop, "thread-1", false);
|
|
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
expect(queryKeys()).toContainEqual([
|
|
"thread",
|
|
"metadata",
|
|
"thread-1",
|
|
false,
|
|
]);
|
|
});
|
|
|
|
test("still refreshes caches when SDK stop rejects", async () => {
|
|
const client = new QueryClient();
|
|
const stop = rs.fn(async () => {
|
|
throw new Error("cancel failed");
|
|
});
|
|
const { queryKeys } = invalidatedQueryKeys(client);
|
|
|
|
await expect(
|
|
stopThreadAndInvalidateCaches(client, stop, "thread-1", false),
|
|
).rejects.toThrow("cancel failed");
|
|
|
|
expect(queryKeys()).toContainEqual(["threads", "search"]);
|
|
expect(queryKeys()).toContainEqual([
|
|
"thread",
|
|
"metadata",
|
|
"thread-1",
|
|
false,
|
|
]);
|
|
});
|
|
|
|
test("schedules sidebar refetch even if stopped thread id is not known", async () => {
|
|
rs.useFakeTimers();
|
|
|
|
const client = new QueryClient();
|
|
const { queryKeys } = invalidatedQueryKeys(client);
|
|
|
|
try {
|
|
await stopThreadAndInvalidateCaches(
|
|
client,
|
|
() => Promise.resolve(),
|
|
null,
|
|
false,
|
|
);
|
|
|
|
const countSearchInvalidations = () =>
|
|
queryKeys().filter(
|
|
(queryKey) =>
|
|
queryKey?.length === 2 &&
|
|
queryKey[0] === "threads" &&
|
|
queryKey[1] === "search",
|
|
).length;
|
|
|
|
expect(countSearchInvalidations()).toBe(1);
|
|
|
|
await rs.advanceTimersByTimeAsync(
|
|
STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS,
|
|
);
|
|
|
|
expect(countSearchInvalidations()).toBe(2);
|
|
expect(queryKeys()).not.toContainEqual(["thread", null]);
|
|
} finally {
|
|
client.clear();
|
|
rs.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("scheduled refetch lets sidebar receive delayed backend title finalization", async () => {
|
|
rs.useFakeTimers();
|
|
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
let finalized = false;
|
|
let fetchCount = 0;
|
|
const observer = new QueryObserver<AgentThread[]>(client, {
|
|
queryKey: ["threads", "search"],
|
|
queryFn: async () => {
|
|
fetchCount += 1;
|
|
return [
|
|
makeThread(
|
|
"thread-1",
|
|
finalized ? "Generated Title" : "New Conversation",
|
|
),
|
|
];
|
|
},
|
|
});
|
|
const unsubscribe = observer.subscribe((result) => {
|
|
void result.status;
|
|
});
|
|
|
|
try {
|
|
await observer.refetch();
|
|
expect(
|
|
client.getQueryData<AgentThread[]>(["threads", "search"])?.[0]?.values
|
|
?.title,
|
|
).toBe("New Conversation");
|
|
|
|
await stopThreadAndInvalidateCaches(
|
|
client,
|
|
() => Promise.resolve(),
|
|
"thread-1",
|
|
false,
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(
|
|
client.getQueryData<AgentThread[]>(["threads", "search"])?.[0]?.values
|
|
?.title,
|
|
).toBe("New Conversation");
|
|
|
|
finalized = true;
|
|
await rs.advanceTimersByTimeAsync(
|
|
STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS,
|
|
);
|
|
|
|
expect(
|
|
client.getQueryData<AgentThread[]>(["threads", "search"])?.[0]?.values
|
|
?.title,
|
|
).toBe("Generated Title");
|
|
expect(fetchCount).toBeGreaterThanOrEqual(3);
|
|
} finally {
|
|
unsubscribe();
|
|
client.clear();
|
|
rs.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("run-created snapshots without archive metadata cannot insert into filtered lists", () => {
|
|
const client = new QueryClient();
|
|
const recentKey = [...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }];
|
|
const archivedKey = [
|
|
...INFINITE_THREADS_QUERY_KEY_PREFIX,
|
|
{ archived: true },
|
|
];
|
|
const empty = makeInfiniteData([[]]);
|
|
client.setQueryData(recentKey, empty);
|
|
client.setQueryData(archivedKey, empty);
|
|
upsertThreadInInfiniteCache(client, makeThread("running-thread"));
|
|
expect(client.getQueryData(recentKey)).toEqual(empty);
|
|
expect(client.getQueryData(archivedKey)).toEqual(empty);
|
|
expect(client.getQueryState(recentKey)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(archivedKey)?.isInvalidated).toBe(true);
|
|
client.clear();
|
|
});
|