mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 17:35:57 +00:00
5819bd8a59
* fix(frontend): paginate workspace chat list beyond 50 threads (#3482) The sidebar 'Recent chats' and /workspace/chats list were hard-capped at the first 50 threads returned by threads.search. Replace the single-shot useThreads() consumers with useInfiniteThreads() and add an IntersectionObserver sentinel to each list so further pages are fetched on demand. In search mode on the chats page, the sentinel is replaced by an explicit 'Load more' button to prevent the observer from draining the entire backend list while the filtered view stays empty. - Add useInfiniteThreads + page-size constant and pure cache helpers (map/filterInfiniteThreadsCache, getInfiniteThreadsNextPageParam) - Mirror rename / delete / stream-finish updates into the new infinite cache so optimistic UI stays consistent - Extend the e2e mock to honour limit/offset slicing - Unit tests for the cache helpers and pagination boundary - Playwright e2e covering chats page + sidebar load-more, and the search-mode guard against runaway auto-pagination - Add en/zh i18n entries for the search-mode load-more button Fixes #3482 * docs(frontend): clarify infinite-threads offset semantics and test post-delete invariant - Add docstring to getInfiniteThreadsNextPageParam explaining that TanStack Query freezes the returned offset into pageParams once, so optimistic cache mutations that shrink page lengths (filterInfiniteThreadsCache on delete) cannot retroactively move the offset backwards. Delete/rename paths reconcile against the backend via invalidateQueries in onSettled. - Add unit test covering the post-delete invariant. - Fix misleading comment in thread-list-infinite-scroll.spec.ts: the thread-search mock does not sort by updated_at; it returns the array in the order provided. Addresses Copilot CR comments on #3485. * fix(frontend): mirror onCreated upsert into infinite cache; add sidebar Load-older button Address review feedback on #3485: - New upsertThreadInInfiniteCache helper; useThreadStream onCreated now upserts into both the legacy ['threads','search'] cache and the new infinite cache, so a freshly created thread appears in the sidebar immediately during streaming instead of only after the run finishes and onSettled invalidates the query. Restores parity with main. - Sidebar Recent Chats now exposes a visible 'Load older chats' button alongside the IntersectionObserver sentinel, so keyboard-only users and environments where IO is unavailable can still reach older conversations. - Add zh-CN / en-US / types entry for chats.loadOlderChats. - Cover the new helper with 3 unit tests (no-op on uninitialised cache, prepend new thread to first page, merge with existing entry without duplication).
417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
/**
|
|
* Shared mock helpers for E2E tests.
|
|
*
|
|
* Intercepts all LangGraph / Backend API endpoints so tests can run without
|
|
* a real backend. Each test file imports `mockLangGraphAPI` and
|
|
* `handleRunStream` from here.
|
|
*/
|
|
|
|
import type { Page, Route } from "@playwright/test";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants — deterministic IDs used across tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
|
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
|
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type MockThread = {
|
|
thread_id: string;
|
|
title?: string;
|
|
updated_at?: string;
|
|
agent_name?: string;
|
|
messages?: unknown[];
|
|
artifacts?: string[];
|
|
};
|
|
|
|
export type MockAgent = {
|
|
name: string;
|
|
description?: string;
|
|
system_prompt?: string;
|
|
};
|
|
|
|
export type MockSkill = {
|
|
name: string;
|
|
description: string;
|
|
category?: string;
|
|
license?: string | null;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type MockAPIOptions = {
|
|
threads?: MockThread[];
|
|
agents?: MockAgent[];
|
|
skills?: MockSkill[];
|
|
};
|
|
|
|
const DEFAULT_SKILLS: MockSkill[] = [
|
|
{
|
|
name: "data-analysis",
|
|
description: "Analyze structured data and produce charts.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "frontend-design",
|
|
description: "Create polished frontend interfaces.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "disabled-skill",
|
|
description: "Hidden from slash autocomplete.",
|
|
category: "public",
|
|
enabled: false,
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mockLangGraphAPI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Mock all LangGraph API endpoints that the frontend calls on page load and
|
|
* during message sending. Without these mocks the pages would hang waiting
|
|
* for a real backend.
|
|
*/
|
|
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|
const threads = options?.threads ?? [];
|
|
const agents = options?.agents ?? [];
|
|
const skills = options?.skills ?? DEFAULT_SKILLS;
|
|
|
|
// Thread search — sidebar thread list & chats list page
|
|
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",
|
|
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
|
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
|
|
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(sliced),
|
|
});
|
|
});
|
|
|
|
// Thread create — called when user sends first message in a new chat
|
|
void page.route("**/api/langgraph/threads", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
thread_id: MOCK_THREAD_ID,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
metadata: {},
|
|
status: "idle",
|
|
values: {},
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Thread update (PATCH) — metadata update after creation
|
|
void page.route("**/api/langgraph/threads/*", (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Thread history — useStream fetches state history on mount
|
|
void page.route("**/api/langgraph/threads/*/history", (route) => {
|
|
const url = route.request().url();
|
|
|
|
// For threads that exist in our mock data, return history with messages
|
|
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
|
if (matchingThread) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify([
|
|
{
|
|
values: {
|
|
title: matchingThread.title ?? "Untitled",
|
|
messages: matchingThread.messages ?? [
|
|
{
|
|
type: "human",
|
|
id: `msg-human-${matchingThread.thread_id}`,
|
|
content: [{ type: "text", text: "Previous question" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: `msg-ai-${matchingThread.thread_id}`,
|
|
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
|
},
|
|
],
|
|
artifacts: matchingThread.artifacts ?? [],
|
|
},
|
|
next: [],
|
|
metadata: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
parent_config: null,
|
|
},
|
|
]),
|
|
});
|
|
}
|
|
|
|
// New threads — empty history
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: "[]",
|
|
});
|
|
});
|
|
|
|
// Thread state — getState for individual thread
|
|
void page.route("**/api/langgraph/threads/*/state", (route) => {
|
|
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",
|
|
body: JSON.stringify({
|
|
values: {
|
|
title: matchingThread?.title ?? "Untitled",
|
|
messages: matchingThread
|
|
? (matchingThread.messages ?? [
|
|
{
|
|
type: "human",
|
|
id: `msg-human-${matchingThread.thread_id}`,
|
|
content: [{ type: "text", text: "Previous question" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: `msg-ai-${matchingThread.thread_id}`,
|
|
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
|
},
|
|
])
|
|
: [],
|
|
artifacts: matchingThread?.artifacts ?? [],
|
|
},
|
|
next: [],
|
|
metadata: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// The URL carries a query string (e.g. `?limit=10&offset=0`), which Playwright
|
|
// glob `*` does NOT cross, so we match with a regex anchored to `/runs`
|
|
// followed by `?` or end-of-string. This must NOT match `/runs/stream`.
|
|
void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => {
|
|
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",
|
|
body: JSON.stringify(
|
|
matchingThread
|
|
? [
|
|
{
|
|
run_id: `run-${matchingThread.thread_id}`,
|
|
thread_id: matchingThread.thread_id,
|
|
assistant_id: "lead_agent",
|
|
status: "success",
|
|
metadata: {},
|
|
kwargs: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at:
|
|
matchingThread.updated_at ?? "2025-01-01T00:00:00Z",
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route(
|
|
/\/api\/threads\/([^/]+)\/runs\/([^/]+)\/messages/,
|
|
(route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const matchingThread = threads.find((t) =>
|
|
url.includes(`/api/threads/${t.thread_id}/runs/`),
|
|
);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: (matchingThread?.messages ?? []).map((message, index) => ({
|
|
run_id: `run-${matchingThread?.thread_id ?? "unknown"}`,
|
|
content: message,
|
|
metadata: { caller: "lead_agent" },
|
|
created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
|
|
})),
|
|
hasMore: false,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
},
|
|
);
|
|
|
|
// Run stream — returns a minimal SSE response with an AI message
|
|
void page.route("**/api/langgraph/runs/stream", handleRunStream);
|
|
void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream);
|
|
|
|
// Models list — model picker dropdown
|
|
void page.route("**/api/models", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
models: [],
|
|
token_usage: { enabled: false },
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Skills list — settings page and slash autocomplete
|
|
void page.route("**/api/skills", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ skills }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Follow-up suggestions — input box auto-suggest after AI response
|
|
void page.route("**/api/threads/*/suggestions", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ suggestions: [] }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Agents list — sidebar & gallery page
|
|
void page.route("**/api/agents", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ agents }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Individual agent — agent chat page
|
|
void page.route("**/api/agents/*", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const agent = agents.find((a) => url.endsWith(`/api/agents/${a.name}`));
|
|
if (agent) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(agent),
|
|
});
|
|
}
|
|
}
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "Agent not found" }),
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleRunStream
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a minimal SSE stream that the LangGraph SDK can parse.
|
|
* The stream returns a single AI message: "Hello from DeerFlow!".
|
|
*/
|
|
export function handleRunStream(route: Route) {
|
|
const events = [
|
|
{
|
|
event: "metadata",
|
|
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
|
|
},
|
|
{
|
|
event: "values",
|
|
data: {
|
|
messages: [
|
|
{
|
|
type: "human",
|
|
id: "msg-human-1",
|
|
content: [{ type: "text", text: "Hello" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "msg-ai-1",
|
|
content: "Hello from DeerFlow!",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{ event: "end", data: {} },
|
|
];
|
|
|
|
const body = events
|
|
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
|
.join("");
|
|
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body,
|
|
});
|
|
}
|