/** * 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; messages?: unknown[]; artifacts?: string[]; goal?: Record | 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; 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) { 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, ) => { 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; 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) ?? {}, 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; 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, } : {}), ...(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; } | 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; }; 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; }; 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; }; 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 = {}, 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, }); }