mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* feat: add assistant turn branching * fix(threads): skip workspace clone when branching from historical turn Workspace files are not checkpointed, so cloning them onto a branch rooted at an older assistant turn leaked files created in a later timeline. Restrict the best-effort workspace copy to branches taken from the latest turn; historical-turn branches now report workspace_clone_mode="skipped_historical_turn" and keep only the restored message history. * style(frontend): fix prettier formatting in e2e mock-api Collapse the branch-title normalization chain onto a single line to satisfy the frontend lint (prettier --check) CI gate. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1113 lines
33 KiB
TypeScript
1113 lines
33 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";
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
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 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,
|
|
};
|
|
|
|
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 },
|
|
});
|
|
|
|
// 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 = 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") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
|
});
|
|
}
|
|
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() === "DELETE") {
|
|
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\/([^/]+)\/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
|
|
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) 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: true } }),
|
|
});
|
|
}
|
|
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,
|
|
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,
|
|
});
|
|
}
|