mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
Merge remote-tracking branch 'origin/main' into codex/im-channel-connections
# Conflicts: # backend/app/gateway/services.py # frontend/src/app/workspace/chats/page.tsx
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
// Issue #3482: the sidebar's "Recent chats" and the /workspace/chats list
|
||||
// page used to stop at the first 50 threads with no way to load more.
|
||||
// `useInfiniteThreads()` + a sentinel near the bottom of each list now
|
||||
// pages through the backend.
|
||||
|
||||
const TOTAL_THREADS = 120;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
|
||||
// Pad index so titles sort deterministically as strings. The thread-search
|
||||
// mock returns threads in the order provided, so paging boundaries are
|
||||
// stable across runs.
|
||||
const index = String(i + 1).padStart(3, "0");
|
||||
return {
|
||||
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
|
||||
title: `Conversation ${index}`,
|
||||
updated_at: `2025-06-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z`,
|
||||
};
|
||||
});
|
||||
|
||||
const FIRST_PAGE_LAST = `Conversation ${String(PAGE_SIZE).padStart(3, "0")}`;
|
||||
const SECOND_PAGE_FIRST = `Conversation ${String(PAGE_SIZE + 1).padStart(3, "0")}`;
|
||||
|
||||
test.describe("Thread list infinite scroll (issue #3482)", () => {
|
||||
test("chats list page loads more threads when scrolling to the bottom", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats");
|
||||
|
||||
const main = page.locator("main");
|
||||
|
||||
// First page renders.
|
||||
await expect(main.getByText(FIRST_PAGE_LAST)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
// Items past the first page have not been fetched yet.
|
||||
await expect(main.getByText(SECOND_PAGE_FIRST)).toHaveCount(0);
|
||||
|
||||
// Scrolling the sentinel into view triggers the next page.
|
||||
const sentinel = page.getByTestId("chats-page-sentinel");
|
||||
await sentinel.scrollIntoViewIfNeeded();
|
||||
|
||||
await expect(main.getByText(SECOND_PAGE_FIRST)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar recent chats loads more threads when scrolling to the bottom", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
// The 50th thread (end of first page) appears in the sidebar.
|
||||
await expect(page.getByText(FIRST_PAGE_LAST).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
// The 51st has not been fetched yet.
|
||||
await expect(page.getByText(SECOND_PAGE_FIRST)).toHaveCount(0);
|
||||
|
||||
// Scroll the sidebar sentinel into view to trigger the next page.
|
||||
const sentinel = page.getByTestId("recent-chat-list-sentinel");
|
||||
await sentinel.scrollIntoViewIfNeeded();
|
||||
|
||||
await expect(page.getByText(SECOND_PAGE_FIRST).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("chats list page does NOT auto-paginate while a search filter is active", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Count search requests via a passive request observer. Using
|
||||
// page.route() here would race with mockLangGraphAPI's fulfill route
|
||||
// (Playwright matches routes in reverse registration order), so the
|
||||
// counter could miss real requests. page.on('request') is a pure
|
||||
// observer and never interferes with routing.
|
||||
let searchRequestCount = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/langgraph/threads/search")) {
|
||||
searchRequestCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
mockLangGraphAPI(page, { threads: THREADS });
|
||||
|
||||
await page.goto("/workspace/chats");
|
||||
|
||||
// Wait for the first page to render so we have a baseline count.
|
||||
await expect(page.locator("main").getByText(FIRST_PAGE_LAST)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const baselineRequests = searchRequestCount;
|
||||
|
||||
// Type a query that matches nothing in the first page (and nothing at
|
||||
// all, since titles are deterministic).
|
||||
await page
|
||||
.getByPlaceholder("Search chats")
|
||||
.fill("zzz-no-such-conversation");
|
||||
|
||||
// The auto-sentinel must be gone; an explicit button takes its place.
|
||||
await expect(page.getByTestId("chats-page-sentinel")).toHaveCount(0);
|
||||
await expect(page.getByTestId("chats-page-load-more")).toBeVisible();
|
||||
|
||||
// Give the IntersectionObserver a couple of frames to misbehave if the
|
||||
// guard regresses. No additional /threads/search calls should fire.
|
||||
await page.waitForTimeout(500);
|
||||
expect(searchRequestCount).toBe(baselineRequests);
|
||||
|
||||
// The explicit button still works as an escape hatch.
|
||||
await page.getByTestId("chats-page-load-more").click();
|
||||
await expect
|
||||
.poll(() => searchRequestCount, { timeout: 10_000 })
|
||||
.toBeGreaterThan(baselineRequests);
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
const skills = options?.skills ?? DEFAULT_SKILLS;
|
||||
|
||||
// Thread search — sidebar thread list & chats list page
|
||||
void page.route("**/api/langgraph/threads/search", (route) => {
|
||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||
const body = threads.map((t) => ({
|
||||
thread_id: t.thread_id,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
@@ -98,10 +98,33 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
status: "idle",
|
||||
values: { title: t.title ?? "Untitled" },
|
||||
}));
|
||||
|
||||
let limit: number | undefined;
|
||||
let offset = 0;
|
||||
try {
|
||||
const postData = route.request().postDataJSON() as {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} | null;
|
||||
if (postData) {
|
||||
if (typeof postData.limit === "number") {
|
||||
limit = postData.limit;
|
||||
}
|
||||
if (typeof postData.offset === "number") {
|
||||
offset = postData.offset;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No / invalid JSON body — fall back to returning the full list.
|
||||
}
|
||||
|
||||
const sliced =
|
||||
typeof limit === "number" ? body.slice(offset, offset + limit) : body;
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(sliced),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { QueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
filterInfiniteThreadsCache,
|
||||
getInfiniteThreadsNextPageParam,
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
mapInfiniteThreadsCache,
|
||||
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}`): 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): AgentThread[] {
|
||||
return Array.from({ length: size }, (_, i) => makeThread(`t-${start + i}`));
|
||||
}
|
||||
|
||||
function makeInfiniteData(pages: AgentThread[][]): InfiniteData<AgentThread[]> {
|
||||
return {
|
||||
pages,
|
||||
pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
describe("getInfiniteThreadsNextPageParam", () => {
|
||||
test("returns next offset when the last page is full", () => {
|
||||
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("returns undefined when the last page is short (end of 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);
|
||||
expect(getInfiniteThreadsNextPageParam([], [page1, []])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("respects a custom page size", () => {
|
||||
const page1 = makePage(0, 5);
|
||||
expect(getInfiniteThreadsNextPageParam(page1, [page1], 5)).toBe(5);
|
||||
expect(getInfiniteThreadsNextPageParam(page1, [page1], 10)).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]?.[0]?.values?.title).toBe("Title a");
|
||||
expect(updated?.pages[1]?.[0]?.thread_id).toBe("c");
|
||||
expect(updated?.pages[1]?.[0]?.values?.title).toBe("renamed");
|
||||
expect(updated?.pages[1]?.[1]?.values?.title).toBe("Title d");
|
||||
});
|
||||
|
||||
test("preserves pageParams", () => {
|
||||
const data = makeInfiniteData([[makeThread("a")]]);
|
||||
const updated = mapInfiniteThreadsCache(data, (t) => t);
|
||||
expect(updated?.pageParams).toEqual(data.pageParams);
|
||||
});
|
||||
});
|
||||
|
||||
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]?.map((t) => t.thread_id)).toEqual(["a"]);
|
||||
expect(filtered?.pages[1]?.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]).toEqual([]);
|
||||
expect(filtered?.pages[1]?.[0]?.thread_id).toBe("b");
|
||||
});
|
||||
|
||||
test("does not regress next offset when an earlier page has been shrunk by a delete", () => {
|
||||
// Simulate two full pages already loaded.
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||
thread_id: `a${i}`,
|
||||
}));
|
||||
const page2 = Array.from({ length: 50 }, (_, i) => ({
|
||||
thread_id: `b${i}`,
|
||||
}));
|
||||
|
||||
// Offset right after fetching page 2 (this is the value TanStack Query
|
||||
// freezes into pageParams).
|
||||
const offsetAfterPage2 = getInfiniteThreadsNextPageParam(
|
||||
page2 as unknown as AgentThread[],
|
||||
[page1, page2] as unknown as AgentThread[][],
|
||||
);
|
||||
expect(offsetAfterPage2).toBe(100);
|
||||
|
||||
// Now a delete mutation runs filterInfiniteThreadsCache and shrinks
|
||||
// page 1 from 50 to 49 entries. TanStack does NOT re-invoke
|
||||
// getNextPageParam on cache mutations; the previously-computed offset
|
||||
// (100) remains the param for the next fetchNextPage() call, so the
|
||||
// helper is consistent with how the library uses its return value.
|
||||
const shrunkPage1 = page1.slice(0, 49);
|
||||
const recomputed = getInfiniteThreadsNextPageParam(
|
||||
page2 as unknown as AgentThread[],
|
||||
[shrunkPage1, page2] as unknown as AgentThread[][],
|
||||
);
|
||||
// We document the recomputed value for completeness, but in practice
|
||||
// useDeleteThread invalidates the query in onSettled, so pages are
|
||||
// refetched from offset 0 rather than relying on this number.
|
||||
expect(recomputed).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertThreadInInfiniteCache", () => {
|
||||
function seedClient(initial?: InfiniteData<AgentThread[]>): QueryClient {
|
||||
const client = new QueryClient();
|
||||
if (initial) {
|
||||
client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function readCache(
|
||||
client: QueryClient,
|
||||
): InfiniteData<AgentThread[]> | 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({
|
||||
pages: [[makeThread("a"), makeThread("b")]],
|
||||
pageParams: [0],
|
||||
});
|
||||
upsertThreadInInfiniteCache(client, makeThread("new"));
|
||||
const cache = readCache(client);
|
||||
expect(cache?.pages[0]?.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({
|
||||
pages: [[existing, makeThread("b")]],
|
||||
pageParams: [0],
|
||||
});
|
||||
// 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]?.map((t) => t.thread_id);
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
expect(cache?.pages[0]?.[0]?.values.title).toBe("Old title");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user