Ryker_Feng 68c0ffdac8
feat(frontend): pin recent chats (#4442)
* feat(frontend): pin recent chats

* fix(threads): address pin-chat review feedback

- Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new
  update_metadata(touch=False) path so unpinning no longer jumps a chat
  to the top of the updated_at-sorted recent list.
- Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching
  the Gateway's actual response (no values/context).
- Namespace the pinned metadata key as deerflow_pinned for consistency
  with deerflow_sidecar / deerflow_branch.
- Cover touch/touch=False behavior in repo + router tests; document the
  e2e mock's updated_at preservation now mirrors production.

* style(frontend): format thread utils test

* fix(threads): make pinned ordering server-side

* test(frontend): keep infinite-scroll fixture order stable

* test(frontend): stabilize lark reconnect e2e

* docs: clarify thread pin metadata contract
2026-07-26 20:47:58 +08:00

1412 lines
43 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_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
// Keep in sync with frontend runtime thread utils and the backend thread_meta
// constant; the mock must mirror the same metadata contract for pin ordering.
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
const MOCK_AUTH_USER = {
id: "default",
email: "default@test.local",
system_role: "admin",
needs_setup: false,
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type MockThread = {
thread_id: string;
title?: string;
updated_at?: string;
agent_name?: string;
metadata?: Record<string, unknown>;
messages?: unknown[];
artifacts?: string[];
goal?: Record<string, unknown> | null;
};
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[];
scheduledTasks?: Array<{
id: string;
thread_id: string | null;
context_mode?: "fresh_thread_per_run" | "reuse_thread";
last_thread_id?: string | null;
title: string;
prompt: string;
schedule_type: "once" | "cron";
schedule_spec: Record<string, unknown>;
timezone: string;
status:
| "enabled"
| "paused"
| "running"
| "completed"
| "failed"
| "cancelled";
next_run_at: string | null;
last_run_at: string | null;
last_run_id: string | null;
last_error: string | null;
run_count: number;
created_at: string;
updated_at: string;
}>;
uploadLimits?: {
max_files: number;
max_file_size: number;
max_total_size: number;
};
features?: {
agentsApiEnabled?: boolean;
browserControlEnabled?: boolean;
};
};
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,
},
];
function isHiddenInputMessage(message: unknown) {
if (typeof message !== "object" || message === null) {
return false;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
return (
typeof additionalKwargs === "object" &&
additionalKwargs !== null &&
Reflect.get(additionalKwargs, "hide_from_ui") === true
);
}
function visibleInputMessages(messages: unknown[]) {
return messages.filter((message) => !isHiddenInputMessage(message));
}
function mockMessageRunId(message: unknown, fallback: string) {
if (typeof message === "object" && message !== null) {
const runId = Reflect.get(message, "run_id");
if (typeof runId === "string" && runId.length > 0) {
return runId;
}
}
return fallback;
}
function visibleRunInputMessages(route: Route) {
try {
const body = route.request().postDataJSON() as {
input?: { messages?: unknown[] };
};
return visibleInputMessages(body.input?.messages ?? []);
} catch {
return [];
}
}
function messageId(message: unknown): string | undefined {
if (typeof message !== "object" || message === null) {
return undefined;
}
const raw = Reflect.get(message, "id");
return typeof raw === "string" ? raw : undefined;
}
function branchMessagesFromTurn(messages: unknown[], targetIds: Set<string>) {
let targetEndIndex = -1;
for (const [index, message] of messages.entries()) {
const id = messageId(message);
if (id && targetIds.has(id)) {
targetEndIndex = Math.max(targetEndIndex, index);
}
}
return targetEndIndex >= 0 ? messages.slice(0, targetEndIndex + 1) : messages;
}
function mockStreamMessages(route?: Route, inputMessages?: unknown[]) {
const submittedMessages = inputMessages
? visibleInputMessages(inputMessages)
: route
? visibleRunInputMessages(route)
: [];
const responseMessage = {
type: "ai",
id: "msg-ai-1",
content: "Hello from DeerFlow!",
};
if (submittedMessages.length > 0) {
return [...submittedMessages, responseMessage];
}
return [
{
type: "human",
id: "msg-human-1",
content: [{ type: "text", text: "Hello" }],
},
responseMessage,
];
}
function runStreamThreadId(route: Route) {
const pathThreadId = /\/threads\/([^/]+)\/runs\/stream/.exec(
new URL(route.request().url()).pathname,
)?.[1];
if (pathThreadId) {
return pathThreadId;
}
try {
const body = route.request().postDataJSON() as {
thread_id?: string;
threadId?: string;
context?: { thread_id?: string };
config?: { configurable?: { thread_id?: string } };
};
return (
body.thread_id ??
body.threadId ??
body.context?.thread_id ??
body.config?.configurable?.thread_id ??
MOCK_THREAD_ID
);
} catch {
return MOCK_THREAD_ID;
}
}
// ---------------------------------------------------------------------------
// 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) {
let threads = [...(options?.threads ?? [])];
const agents = options?.agents ?? [];
const skills = options?.skills ?? DEFAULT_SKILLS;
const scheduledTasks = options?.scheduledTasks ?? [];
let mutableScheduledTasks = [...scheduledTasks];
const mutableTaskRuns: Record<
string,
Array<{
id: string;
task_id: string;
thread_id: string | null;
run_id: string | null;
scheduled_for: string;
trigger: "scheduled" | "manual";
status: "queued" | "running" | "success" | "failed" | "skipped";
error: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}>
> = {};
const uploadLimits = options?.uploadLimits ?? {
max_files: 10,
max_file_size: 50 * 1024 * 1024,
max_total_size: 100 * 1024 * 1024,
};
let larkIntegrationStatus = {
installed: false,
version: "v1.0.65",
manifest_version: null as string | null,
latest_available_version: "v1.0.65" as string | null,
runtime_version_mismatch: false,
app_configured: false,
app_id: null as string | null,
app_brand: null as string | null,
skills_expected: 27,
skills_installed: 0,
installed_skills: [] as string[],
enabled_skills: [] as string[],
install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
cli: {
available: false,
path: null as string | null,
version: null as string | null,
error: "lark-cli is not on PATH" as string | null,
},
auth: {
status: "unavailable",
message: "lark-cli is not installed on the Gateway" as string | null,
user: null as string | null,
verified: false,
},
sandbox_runtime_mode: "init-container" as
| "none"
| "gateway-download"
| "init-container",
sandbox_runtime_ready: false,
sandbox_runtime_detail:
"The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)." as
| string
| null,
};
const featureFlags = {
agentsApiEnabled: options?.features?.agentsApiEnabled ?? true,
browserControlEnabled: options?.features?.browserControlEnabled ?? true,
};
const upsertThread = (thread: MockThread) => {
threads = [
thread,
...threads.filter((existing) => existing.thread_id !== thread.thread_id),
];
};
const threadSearchResult = (thread: MockThread) => ({
thread_id: thread.thread_id,
created_at: "2025-01-01T00:00:00Z",
updated_at: thread.updated_at ?? "2025-01-01T00:00:00Z",
metadata: {
...(thread.metadata ?? {}),
...(thread.agent_name ? { agent_name: thread.agent_name } : {}),
},
status: "idle",
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
});
const threadUpdatedAt = (thread: MockThread) =>
Date.parse(thread.updated_at ?? "2025-01-01T00:00:00Z") || 0;
const sortThreadSearchResults = (items: readonly MockThread[]) =>
[...items].sort((left, right) => {
const pinnedDiff =
Number(right.metadata?.[THREAD_PINNED_METADATA_KEY] === true) -
Number(left.metadata?.[THREAD_PINNED_METADATA_KEY] === true);
return (
pinnedDiff ||
threadUpdatedAt(right) - threadUpdatedAt(left) ||
right.thread_id.localeCompare(left.thread_id)
);
});
const patchThreadMetadata = (
threadId: string,
metadata: Record<string, unknown>,
) => {
let updated: MockThread | undefined;
threads = threads.map((thread) => {
if (thread.thread_id !== threadId) {
return thread;
}
// Preserve ``updated_at`` for pin/unpin metadata changes; the search mock
// below mirrors the Gateway's server-side pinned-first ordering.
updated = {
...thread,
metadata: {
...(thread.metadata ?? {}),
...metadata,
},
};
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") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_AUTH_USER),
});
}
return route.fallback();
});
void page.route("**/api/v1/auth/setup-status", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ needs_setup: false }),
});
}
return route.fallback();
});
void page.route("**/api/v1/auth/logout", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ status: 204 });
}
return route.fallback();
});
void page.route("**/api/channels/providers", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false, providers: [] }),
});
}
return route.fallback();
});
void page.route("**/api/channels/connections", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
}
return route.fallback();
});
void page.route("**/api/suggestions/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
mutableScheduledTasks.map((task) => ({
context_mode: "fresh_thread_per_run",
last_thread_id: null,
...task,
thread_id: task.thread_id ?? null,
})),
),
});
}
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>;
const threadId =
typeof payload.thread_id === "string" ? payload.thread_id : "";
const title = typeof payload.title === "string" ? payload.title : "";
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
const timezone =
typeof payload.timezone === "string" ? payload.timezone : "UTC";
const created = {
id: "task-created",
thread_id: threadId || null,
context_mode:
(payload.context_mode as "fresh_thread_per_run" | "reuse_thread") ??
"fresh_thread_per_run",
last_thread_id: null,
title,
prompt,
schedule_type: payload.schedule_type as "once" | "cron",
schedule_spec: (payload.schedule_spec as Record<string, unknown>) ?? {},
timezone,
status: "enabled" as const,
next_run_at: null,
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
};
mutableScheduledTasks = [created, ...mutableScheduledTasks];
mutableTaskRuns[created.id] = [];
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(created),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/pause", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.map((task) =>
task.id === taskId ? { ...task, status: "paused" as const } : task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(task),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/resume", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.map((task) =>
task.id === taskId ? { ...task, status: "enabled" as const } : task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(task),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/trigger", (route) => {
if (route.request().method() === "POST") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
if (task) {
const runId = `run-${taskId}`;
mutableTaskRuns[taskId] = [
{
id: `task-run-${taskId}`,
task_id: taskId,
thread_id: task.thread_id,
run_id: runId,
scheduled_for: "2026-07-01T00:00:00+00:00",
trigger: "manual",
status: "success",
error: null,
started_at: "2026-07-01T00:00:00+00:00",
finished_at: "2026-07-01T00:00:00+00:00",
created_at: "2026-07-01T00:00:00+00:00",
},
...(mutableTaskRuns[taskId] ?? []),
];
mutableScheduledTasks = mutableScheduledTasks.map((item) =>
item.id === taskId
? {
...item,
last_run_id: runId,
last_run_at: "2026-07-01T00:00:00+00:00",
run_count: item.run_count + 1,
}
: item,
);
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: taskId, triggered: true }),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*", (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const taskId = decodeURIComponent(
new URL(request.url()).pathname.split("/").at(-1) ?? "",
);
const payload = request.postDataJSON() as Record<string, unknown>;
let updated: (typeof mutableScheduledTasks)[number] | undefined;
mutableScheduledTasks = mutableScheduledTasks.map((task) => {
if (task.id !== taskId) {
return task;
}
updated = {
...task,
...(typeof payload.title === "string"
? { title: payload.title }
: {}),
...(typeof payload.prompt === "string"
? { prompt: payload.prompt }
: {}),
...(payload.schedule_spec
? {
schedule_spec: payload.schedule_spec as Record<string, unknown>,
}
: {}),
...(typeof payload.timezone === "string"
? { timezone: payload.timezone }
: {}),
updated_at: "2026-07-01T00:00:00+00:00",
};
return updated;
});
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(updated ?? {}),
});
}
if (request.method() === "DELETE") {
const taskId = decodeURIComponent(
new URL(request.url()).pathname.split("/").at(-1) ?? "",
);
mutableScheduledTasks = mutableScheduledTasks.filter(
(task) => task.id !== taskId,
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: taskId, deleted: true }),
});
}
return route.fallback();
});
void page.route("**/api/threads/*/scheduled-tasks", (route) => {
if (route.request().method() === "GET") {
const url = new URL(route.request().url());
const parts = url.pathname.split("/");
const threadId = decodeURIComponent(
parts[parts.indexOf("threads") + 1] ?? "",
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
mutableScheduledTasks
.filter((task) => task.thread_id === threadId)
.map((task) => ({
context_mode: "fresh_thread_per_run",
last_thread_id: null,
...task,
thread_id: task.thread_id ?? null,
})),
),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/runs", (route) => {
if (route.request().method() === "GET") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mutableTaskRuns[taskId] ?? []),
});
}
return route.fallback();
});
// Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", async (route) => {
let body = sortThreadSearchResults(threads).map(threadSearchResult);
let limit: number | undefined;
let offset = 0;
try {
const postData = route.request().postDataJSON() as {
limit?: number;
offset?: number;
metadata?: Record<string, unknown>;
} | null;
if (postData) {
if (typeof postData.limit === "number") {
limit = postData.limit;
}
if (typeof postData.offset === "number") {
offset = postData.offset;
}
if (postData.metadata && typeof postData.metadata === "object") {
body = body.filter((thread) =>
Object.entries(postData.metadata ?? {}).every(
([key, value]) => thread.metadata?.[key] === value,
),
);
}
}
} 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") {
upsertThread({
thread_id: MOCK_THREAD_ID,
title: "New Chat",
updated_at: new Date().toISOString(),
messages: mockStreamMessages(),
});
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) => {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
);
const matchingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
if (route.request().method() === "GET") {
if (!matchingThread) {
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(threadSearchResult(matchingThread)),
});
}
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as {
metadata?: Record<string, unknown>;
};
const updated = body.metadata
? patchThreadMetadata(threadId, body.metadata)
: 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(threadSearchResult(updated)),
});
}
if (route.request().method() === "DELETE") {
threads = threads.filter((thread) => thread.thread_id !== threadId);
return route.fulfill({
status: 204,
});
}
return route.fallback();
});
void page.route("**/api/threads", (route) => {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
thread_id?: string;
metadata?: Record<string, unknown>;
};
const threadId = body.thread_id ?? MOCK_SIDECAR_THREAD_ID;
upsertThread({
thread_id: threadId,
title: "Side chat",
updated_at: new Date().toISOString(),
metadata: body.metadata ?? {},
messages: [],
});
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
thread_id: threadId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: body.metadata ?? {},
status: "idle",
values: {},
}),
});
}
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
if (route.request().method() === "PATCH") {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
);
const body = route.request().postDataJSON() as {
metadata?: Record<string, unknown>;
};
const matchingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
const updated = body.metadata
? patchThreadMetadata(threadId, body.metadata)
: matchingThread;
if (!updated) {
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(threadSearchResult(updated)),
});
}
if (route.request().method() === "DELETE") {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
);
// Mirror the gateway's `require_existing=True` ownership guard: deleting
// an already-removed thread 404s. `useDeleteThread` first deletes via the
// LangGraph route (which drops the thread_meta row) and then hits this
// route, so this reproduces the real double-delete 404 the frontend must
// treat as idempotent success.
if (!threads.some((thread) => thread.thread_id === threadId)) {
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
});
}
threads = threads.filter((thread) => thread.thread_id !== threadId);
return route.fulfill({
status: 204,
});
}
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+\/branches$/, (route) => {
if (route.request().method() === "POST") {
const pathParts = new URL(route.request().url()).pathname.split("/");
const sourceThreadId = decodeURIComponent(pathParts.at(-2) ?? "");
const sourceThread = threads.find(
(thread) => thread.thread_id === sourceThreadId,
);
const body = route.request().postDataJSON() as {
message_id?: string;
message_ids?: string[];
title?: string;
};
const targetIds = new Set(
[body.message_id, ...(body.message_ids ?? [])].filter(
(id): id is string => typeof id === "string" && id.length > 0,
),
);
let sourceTitle = sourceThread?.title?.trim();
if (sourceThread?.metadata?.deerflow_branch === true) {
sourceTitle = sourceTitle?.replace(/^(Branch:\s*)+/i, "").trim();
}
const title = body.title ?? sourceTitle;
upsertThread({
thread_id: MOCK_THREAD_ID_2,
title,
updated_at: new Date().toISOString(),
metadata: {
deerflow_branch: true,
branch_parent_thread_id: sourceThreadId,
branch_parent_message_id: body.message_id,
branch_parent_checkpoint_id: "mock-checkpoint",
},
messages: branchMessagesFromTurn(
sourceThread?.messages ?? [],
targetIds,
),
});
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
thread_id: MOCK_THREAD_ID_2,
parent_thread_id: sourceThreadId,
parent_checkpoint_id: "mock-checkpoint",
branched_from_message_id: body.message_id,
workspace_clone_mode: "current_thread_best_effort",
}),
});
}
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+\/goal$/, async (route) => {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
let matchingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal: matchingThread?.goal ?? null }),
});
}
if (route.request().method() === "DELETE") {
if (matchingThread) {
matchingThread.goal = null;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal: null }),
});
}
if (route.request().method() === "PUT") {
const payload = route.request().postDataJSON() as {
objective?: string;
};
const goal = {
objective: payload.objective ?? "",
status: "active",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
continuation_count: 0,
max_continuations: 8,
no_progress_count: 0,
max_no_progress_continuations: 2,
};
matchingThread ??= {
thread_id: threadId,
title: "New Chat",
updated_at: new Date().toISOString(),
};
upsertThread({ ...matchingThread, goal });
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal }),
});
}
return route.fallback();
});
void page.route("**/api/threads/*/uploads/limits", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(uploadLimits),
});
}
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",
goal: matchingThread.goal ?? null,
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",
goal: matchingThread?.goal ?? null,
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\/([^/]+)\/messages\/page/, (route) => {
if (route.request().method() === "GET") {
const url = route.request().url();
const matchingThread = threads.find((t) =>
url.includes(`/api/threads/${t.thread_id}/messages/page`),
);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: (matchingThread?.messages ?? []).map((message, index) => ({
run_id: mockMessageRunId(
message,
`run-${matchingThread?.thread_id ?? "unknown"}`,
),
seq: index + 1,
content: message,
metadata: { caller: "lead_agent" },
created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
})),
has_more: false,
next_before_seq: null,
}),
});
}
return route.fallback();
});
// Run stream — returns a minimal SSE response with an AI message
const handleMockRunStream = (route: Route) => {
const threadId = runStreamThreadId(route);
const existingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
const goal = existingThread?.goal ?? fallbackGoal;
upsertThread({
thread_id: threadId,
title: threadId === MOCK_SIDECAR_THREAD_ID ? "Side chat" : "New Chat",
updated_at: new Date().toISOString(),
goal,
metadata: existingThread?.metadata,
messages: mockStreamMessages(route),
});
return handleRunStream(route, { goal });
};
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
void page.route(
"**/api/langgraph/threads/*/runs/stream",
handleMockRunStream,
);
// 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();
});
// Feature flags — frontend gates UI (e.g. agents/browser) on these. Default to
// enabled so existing tests exercise the normal path; tests that need the
// disabled state override this route after calling mockLangGraphAPI.
void page.route("**/api/features", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agents_api: { enabled: featureFlags.agentsApiEnabled },
browser_control: { enabled: featureFlags.browserControlEnabled },
}),
});
}
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();
});
void page.route("**/api/integrations/lark/status", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(larkIntegrationStatus),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/install", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
installed: true,
version: "v1.0.65",
manifest_version: "v1.0.65",
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
skills_expected: 27,
skills_installed: 3,
installed_skills: ["lark-doc", "lark-im", "lark-shared"],
enabled_skills: ["lark-doc", "lark-im", "lark-shared"],
install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
cli: {
available: true,
path: "/usr/bin/lark-cli",
version: "lark-cli version v1.0.65",
error: null,
},
auth: {
status: "not_configured",
message: "lark-cli auth is not configured",
user: null,
verified: false,
},
sandbox_runtime_mode: "init-container",
sandbox_runtime_ready: true,
sandbox_runtime_detail: null,
};
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
installed_skills: ["lark-doc", "lark-im", "lark-shared"],
message: "Installed 3 Lark/Feishu skills.",
status: larkIntegrationStatus,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/config/start", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "mock-config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/config/complete", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
...larkIntegrationStatus,
app_configured: true,
app_id: "cli_mock",
app_brand: "feishu",
auth: {
status: "not_authorized",
message: "Lark user authorization is not configured",
user: null,
verified: false,
},
};
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Lark/Feishu connection setup completed.",
status: larkIntegrationStatus,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/auth/start", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code",
expires_in: 600,
user_code: null,
hint: null,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/auth/complete", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
...larkIntegrationStatus,
auth: {
status: "authenticated",
message: "Lark/Feishu authorization is live-verified.",
user: "Alice",
verified: true,
},
};
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Lark/Feishu authorization completed.",
status: larkIntegrationStatus,
}),
});
}
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,
values: Record<string, unknown> = {},
inputMessages?: unknown[],
) {
const threadId = runStreamThreadId(route);
const events = [
{
event: "metadata",
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
},
{
event: "values",
data: {
...values,
messages: mockStreamMessages(route, inputMessages),
},
},
{ 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,
});
}