mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 03:26:18 +00:00
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md (issue #5160, tracker #5129) in the slice order of the spec's §16. Slices: - A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY admission pinning (both server-owned sets + worker hoist); latest-only request-scoped <project> block via DynamicContextMiddleware wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID prefix + marker + provenance, never persisted); journal audit fingerprints; Instructions tab. - B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with locked check-and-set; hash-qualified immutable shelf storage with Paths helpers; upload/list/content/delete-to-trash routes; project delete trashes the shelf in-transaction; request-scoped bounded <documents> index with honest count/shown + actionable overflow note; list_project_documents/read_project_document tools registered only on pinned runs; PAT allowlist + drift guards; blocking-IO anchors. - C: shared thread-upload ingestion service (uploads router refactored to parity); POST from-thread with provenance; attach-to-thread with lock-staged copy (archived source allowed); read-only thread-files view with per-group truncation reporting. - D: restore (restored/merged/not_found/no_target/content_missing; no file moves), purge (continuous row lock across unlink/delete/commit, retryable on FS errors), retention sweep (lazy + startup, 24h orphan guard, row-side reconciliation never deletes). - E: Documents tab (shelf + conversation-files browser, provenance, archived banner, content-missing rows), /workspace/trash route, sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks + specs. Review hardening folded in (10 rounds, all with tests): - force active shelf content (HTML/XML family) to download; nosniff on artifact + content responses; unified unsandboxed-iframe PDF preview (fixes the pre-existing Chromium sandbox blank in the artifact viewer) - scope document trash to the URL project under the document lock - atomic no-overwrite filename reservation for ALL ingestion (seeded claims + os.link commit with suffix retry; same-name re-upload now unique-names instead of replacing); hidden staging only, no visible placeholders; lease cleanup on setup failure - serialize conversion under the document lock with post-lock active revalidation; drain locked filesystem work on cancellation; preserve bytes when an insert's commit state is uncertain (including trashed rows) - original-integrity checks before serving text or cached conversions; content_missing surfaced in list responses (UI reads the flag, no 409-probe); downloads always serve original bytes - bounded streaming document reads with cached char counts; shelf limits declared in middleware release identity - thread-root confinement for from-thread sources; config fallback rejects fractional/infinite values; composer counts staged attachments; pending attachments persist until submission or removal; in-flight instruction/rename edits survive save refetches; shelf and trash pagination; conversation-file and thread-files pages stay subscribed to refetches Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md contracts, config.example.yaml projects block. Review follow-ups (head b4807477 → this revision): - The trash retention sweep is split so repeated lazy triggers stay bounded: the indexed expiry purge still runs on every trigger (GET /api/trash/documents, POST /api/trash/purge) while the O(all rows + all files) reconciliation is throttled to one run per user per 15 minutes (process-local, per-user window). The startup sweep now runs as a background task instead of blocking gateway readiness, and shutdown awaits it (bounded). - The export scrub (stripInternalMarkers) is fence- and indentation-aware like the render path, so a pasted, fenced <project>/<documents> snippet survives markdown export while real injected blocks (never fenced) are still removed. Fence regexes moved to a dependency-free leaf module to avoid the messages↔streamdown import cycle. - The artifact viewer's PDF iframe no longer carries an added title attribute (the upstream e2e contract locates it via :not([title])), and the upstream artifact-preview spec now pins the new contract: PDFs render unsandboxed, images keep sandbox="". * fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title - Shutdown cancelled only the shield around the background startup sweep, so an all-users reconciliation that outlived the 5s budget kept walking rows and files while the document repo and DB engine were disposed underneath it. The wait now lives in `_shutdown_startup_trash_sweep`, which cancels the task and drains it before worker exit: the shield keeps the wait bounded, the cancel makes it final (CancelledError lands at the sweep's next await, and `_run_startup_trash_sweep` only catches `Exception`, so nothing swallows it). - The browser-preview iframe lost `title={getFileName(filepath)}` in the previous fix round, leaving the PDF frame without an accessible name while its siblings keep theirs. Restore it (WCAG frame titles), assert it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]` instead of `iframe:not([title])`. * fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel `Task.cancel()` returns False when the sweep already finished inside the window between the deadline firing and the cancel, so the shutdown log claimed a cancellation that never happened. Branch on that outcome: the warning stays for a real cancel, a late finish is logged at info, and both paths still reap the task before worker exit. * fix(projects): round-4 review — make Empty trash delete what it confirms `POST /api/trash/purge` only ran the retention sweep, and the sweep's candidate selection is age-gated, so a freshly trashed document survived "Empty trash" even though the confirmation promises that every listed document is permanently deleted. With one trashed row the route answered `{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps expired rows before listing, so the visible rows were normally ineligible for the action by construction. Empty trash now drives `purge_all_trashed`: the caller's trashed rows (`list_all_trashed`, no age filter) each go through the same guarded, row-locked `purge` as the single-document delete — bytes first, then the row, in one transaction — so a row restored mid-flight is skipped instead of force-deleted, and an unlink failure rolls that row back and answers 500 with a retryable message. Retention expiry stays where it was: the sweep's `purge_candidates` is now the only age-gated selection, and the lazy retention sweep still runs on the listing and at startup. Tests: the router suite replaces the retention-gated expectation with the reviewer's repro (fresh row purged, bytes unlinked, shelf and other users' trash untouched, a failing unlink stays retryable and 500); a blocking-I/O anchor drives the new entry point through the offload; the mocked e2e covers the action end to end; a new real-backend spec performs it against the real gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE and the phase-2 design docs (en+zh) state the age-independent contract.
2146 lines
67 KiB
TypeScript
2146 lines
67 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";
|
|
|
|
// Keep in sync with frontend runtime thread utils and the backend thread_meta
|
|
// constant; the mock must mirror the same metadata contract for project
|
|
// membership.
|
|
export const THREAD_PROJECT_METADATA_KEY = "deerflow_project_id";
|
|
|
|
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 MockProject = {
|
|
id: string;
|
|
name: string;
|
|
instructions?: string;
|
|
presentation?: Record<string, unknown>;
|
|
status?: "active" | "archived";
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
};
|
|
|
|
export type MockProjectDocument = {
|
|
id: string;
|
|
project_id: string;
|
|
name: string;
|
|
size_bytes: number;
|
|
sha256?: string;
|
|
content_missing?: boolean;
|
|
source_thread_id?: string | null;
|
|
source_kind?: "upload" | "output" | null;
|
|
source_name?: string | null;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
};
|
|
|
|
export type MockTrashDocument = MockProjectDocument & {
|
|
trashed_at: string;
|
|
trash_origin?: { project_id: string; project_name: string } | null;
|
|
};
|
|
|
|
export type MockThreadFileGroup = {
|
|
thread_id: string;
|
|
display_name: string;
|
|
updated_at: string;
|
|
files: Array<{
|
|
kind: "upload" | "output";
|
|
name: string;
|
|
size_bytes: number;
|
|
modified_at: string;
|
|
artifact_url: string;
|
|
}>;
|
|
truncated?: boolean;
|
|
};
|
|
|
|
export type MockAgent = {
|
|
name: string;
|
|
display_name?: string | null;
|
|
description?: string;
|
|
system_prompt?: string;
|
|
tool_groups?: string[] | null;
|
|
skills?: string[] | null;
|
|
};
|
|
|
|
export type MockSkill = {
|
|
name: string;
|
|
description: string;
|
|
category?: string;
|
|
license?: string | null;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type MockAPIOptions = {
|
|
threads?: MockThread[];
|
|
projects?: MockProject[];
|
|
projectsConfig?: {
|
|
instructions_max_bytes?: number;
|
|
trash_retention_days?: number;
|
|
};
|
|
projectDocuments?: MockProjectDocument[];
|
|
trashDocuments?: MockTrashDocument[];
|
|
threadFileGroups?: MockThreadFileGroup[];
|
|
createdThreadMessages?: unknown[];
|
|
agents?: MockAgent[];
|
|
skills?: MockSkill[];
|
|
scheduledTasks?: Array<{
|
|
id: string;
|
|
thread_id: string | null;
|
|
context_mode?: "fresh_thread_per_run" | "reuse_thread";
|
|
assistant_id?: string | null;
|
|
last_thread_id?: string | null;
|
|
title: string;
|
|
prompt: string;
|
|
schedule_type: "once" | "cron" | "interval";
|
|
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;
|
|
mcpTasksEnabled?: boolean;
|
|
};
|
|
runStreamHandler?: (route: Route) => Promise<void>;
|
|
};
|
|
|
|
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[],
|
|
responseMessage: Record<string, unknown> = {
|
|
type: "ai",
|
|
id: "msg-ai-1",
|
|
content: "Hello from DeerFlow!",
|
|
},
|
|
) {
|
|
const submittedMessages = inputMessages
|
|
? visibleInputMessages(inputMessages)
|
|
: route
|
|
? visibleRunInputMessages(route)
|
|
: [];
|
|
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 projectsList = (options?.projects ?? []).map((project) => ({
|
|
instructions: "",
|
|
presentation: {},
|
|
status: "active" as const,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
...project,
|
|
}));
|
|
let projectDocuments = [...(options?.projectDocuments ?? [])];
|
|
let trashDocuments = [...(options?.trashDocuments ?? [])];
|
|
const threadFileGroups = [...(options?.threadFileGroups ?? [])];
|
|
let projectDocumentSequence = 0;
|
|
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"
|
|
| "launching"
|
|
| "running"
|
|
| "success"
|
|
| "failed"
|
|
| "skipped"
|
|
| "interrupted";
|
|
error: string | null;
|
|
attempt_count: number;
|
|
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,
|
|
mcpTasksEnabled: options?.features?.mcpTasksEnabled ?? 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;
|
|
};
|
|
|
|
const patchThreadTitle = (threadId: string, title: string) => {
|
|
let updated: MockThread | undefined;
|
|
threads = threads.map((thread) => {
|
|
if (thread.thread_id !== threadId) {
|
|
return thread;
|
|
}
|
|
updated = { ...thread, title };
|
|
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",
|
|
assistant_id:
|
|
typeof payload.assistant_id === "string"
|
|
? payload.assistant_id
|
|
: "lead_agent",
|
|
last_thread_id: null,
|
|
title,
|
|
prompt,
|
|
schedule_type: payload.schedule_type as "once" | "cron" | "interval",
|
|
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,
|
|
attempt_count: 1,
|
|
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 url = new URL(route.request().url());
|
|
const taskId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
|
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(
|
|
(mutableTaskRuns[taskId] ?? []).slice(offset, offset + limit),
|
|
),
|
|
});
|
|
}
|
|
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 {
|
|
archived?: boolean;
|
|
limit?: number;
|
|
offset?: number;
|
|
metadata?: Record<string, unknown>;
|
|
} | null;
|
|
if (postData) {
|
|
if (typeof postData.archived === "boolean") {
|
|
body = body.filter(
|
|
(thread) =>
|
|
(Reflect.get(thread.metadata, "deerflow_archived") === true) ===
|
|
postData.archived,
|
|
);
|
|
}
|
|
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: options?.createdThreadMessages ?? 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>;
|
|
project_id?: string;
|
|
};
|
|
const threadId = body.thread_id ?? MOCK_SIDECAR_THREAD_ID;
|
|
// The backend stamps `metadata.deerflow_project_id` from the assigned
|
|
// project_id column; mirror that so project membership is readable.
|
|
const metadata = {
|
|
...body.metadata,
|
|
...(body.project_id
|
|
? { [THREAD_PROJECT_METADATA_KEY]: body.project_id }
|
|
: {}),
|
|
};
|
|
// Mirror the backend idempotency contract: a repeat POST for an
|
|
// existing thread_id returns the record unchanged (goal and other
|
|
// state intact) instead of resetting it.
|
|
const existing = threads.find((thread) => thread.thread_id === threadId);
|
|
if (existing) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
thread_id: threadId,
|
|
created_at: existing.updated_at ?? new Date().toISOString(),
|
|
updated_at: existing.updated_at ?? new Date().toISOString(),
|
|
metadata: existing.metadata ?? {},
|
|
status: "idle",
|
|
values: {},
|
|
}),
|
|
});
|
|
}
|
|
upsertThread({
|
|
thread_id: threadId,
|
|
title: "Side chat",
|
|
updated_at: new Date().toISOString(),
|
|
metadata: 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: 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();
|
|
});
|
|
|
|
// Projects API — Phase 1 default-empty mocks so existing specs are
|
|
// unaffected; project-aware specs register their own routes on top.
|
|
void page.route(/\/api\/projects(\?|$)/, (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ projects: projectsList }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Phase 2 project/trash API — default-empty mocks so project-unaware specs
|
|
// are unaffected; project specs seed rows via MockAPIOptions and drive the
|
|
// stateful routes below (upload -> shelf -> attach -> trash -> restore ->
|
|
// purge mirrors the gateway's lifecycle, spec §6.5/§8).
|
|
|
|
const json = (status: number, body: unknown) => ({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
const notFound = (detail: string) => json(404, { detail });
|
|
const projectDocumentResponse = (document: MockProjectDocument) => ({
|
|
id: document.id,
|
|
name: document.name,
|
|
size_bytes: document.size_bytes,
|
|
sha256: document.sha256 ?? "mock-sha256",
|
|
content_missing: document.content_missing ?? false,
|
|
source_thread_id: document.source_thread_id ?? null,
|
|
source_kind: document.source_kind ?? null,
|
|
source_name: document.source_name ?? null,
|
|
created_at: document.created_at ?? "2026-01-01T00:00:00Z",
|
|
updated_at: document.updated_at ?? "2026-01-01T00:00:00Z",
|
|
});
|
|
const findProject = (projectId: string) =>
|
|
projectsList.find((project) => project.id === projectId);
|
|
|
|
void page.route(/\/api\/projects\/([^/]+)$/, (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
const projectId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
|
);
|
|
const project = findProject(projectId);
|
|
if (!project) {
|
|
return route.fulfill(notFound("Project not found"));
|
|
}
|
|
return route.fulfill(json(200, project));
|
|
});
|
|
|
|
// Registered after the catch-all ``/api/projects/{id}`` route so the last-
|
|
// registered-wins order lets ``config`` resolve here instead of 404ing as
|
|
// a project id — the gateway declares the route before ``/{project_id}``
|
|
// for the same reason.
|
|
void page.route(/\/api\/projects\/config$/, (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
return route.fulfill(
|
|
json(200, {
|
|
instructions_max_bytes:
|
|
options?.projectsConfig?.instructions_max_bytes ?? 8192,
|
|
trash_retention_days:
|
|
options?.projectsConfig?.trash_retention_days ?? 30,
|
|
}),
|
|
);
|
|
});
|
|
|
|
void page.route(/\/api\/projects\/([^/]+)\/threads$/, (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
const projectId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
if (!findProject(projectId)) {
|
|
return route.fulfill(notFound("Project not found"));
|
|
}
|
|
const members = threads
|
|
.filter(
|
|
(thread) =>
|
|
thread.metadata?.[THREAD_PROJECT_METADATA_KEY] === projectId,
|
|
)
|
|
.map((thread) => ({
|
|
thread_id: thread.thread_id,
|
|
display_name: thread.title ?? null,
|
|
metadata: thread.metadata ?? {},
|
|
created_at: thread.updated_at,
|
|
updated_at: thread.updated_at,
|
|
}));
|
|
return route.fulfill(json(200, members));
|
|
});
|
|
|
|
void page.route(/\/api\/projects\/([^/]+)\/documents(?:\?|$)/, (route) => {
|
|
const projectId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
if (!findProject(projectId)) {
|
|
return route.fulfill(notFound("Project not found"));
|
|
}
|
|
if (route.request().method() === "GET") {
|
|
const search = new URL(route.request().url()).searchParams;
|
|
const limit = Number(search.get("limit") ?? 100);
|
|
const offset = Number(search.get("offset") ?? 0);
|
|
const all = projectDocuments
|
|
.filter((document) => document.project_id === projectId)
|
|
.map(projectDocumentResponse);
|
|
return route.fulfill(
|
|
json(200, {
|
|
documents: all.slice(offset, offset + limit),
|
|
total: all.length,
|
|
limit,
|
|
offset,
|
|
}),
|
|
);
|
|
}
|
|
if (route.request().method() === "POST") {
|
|
const body = route.request().postData() ?? "";
|
|
const filename = /filename="([^"]+)"/.exec(body)?.[1] ?? "upload.bin";
|
|
projectDocumentSequence += 1;
|
|
const document: MockProjectDocument = {
|
|
id: `mock-doc-${projectDocumentSequence}`,
|
|
project_id: projectId,
|
|
name: filename,
|
|
size_bytes: body.length,
|
|
source_thread_id: null,
|
|
source_kind: null,
|
|
source_name: null,
|
|
};
|
|
projectDocuments = [...projectDocuments, document];
|
|
return route.fulfill(
|
|
json(201, {
|
|
document: projectDocumentResponse(document),
|
|
deduplicated: false,
|
|
}),
|
|
);
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route(
|
|
/\/api\/projects\/([^/]+)\/documents\/([^/]+)\/content$/,
|
|
(route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
const parts = new URL(route.request().url()).pathname.split("/");
|
|
const projectId = decodeURIComponent(parts.at(-4) ?? "");
|
|
const documentId = decodeURIComponent(parts.at(-2) ?? "");
|
|
const document = projectDocuments.find(
|
|
(candidate) =>
|
|
candidate.id === documentId && candidate.project_id === projectId,
|
|
);
|
|
if (!document) {
|
|
return route.fulfill(notFound("Project document not found"));
|
|
}
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/plain",
|
|
body: `mock content of ${document.name}`,
|
|
});
|
|
},
|
|
);
|
|
|
|
void page.route(/\/api\/projects\/([^/]+)\/documents\/([^/]+)$/, (route) => {
|
|
if (route.request().method() !== "DELETE") {
|
|
return route.fallback();
|
|
}
|
|
const parts = new URL(route.request().url()).pathname.split("/");
|
|
const projectId = decodeURIComponent(parts.at(-3) ?? "");
|
|
const documentId = decodeURIComponent(parts.at(-1) ?? "");
|
|
const document = projectDocuments.find(
|
|
(candidate) =>
|
|
candidate.id === documentId && candidate.project_id === projectId,
|
|
);
|
|
if (!document) {
|
|
return route.fulfill(notFound("Project document not found"));
|
|
}
|
|
const project = findProject(projectId);
|
|
projectDocuments = projectDocuments.filter(
|
|
(candidate) => candidate.id !== documentId,
|
|
);
|
|
trashDocuments = [
|
|
{
|
|
...document,
|
|
trashed_at: new Date().toISOString(),
|
|
trash_origin: {
|
|
project_id: projectId,
|
|
project_name: project?.name ?? "Unknown",
|
|
},
|
|
},
|
|
...trashDocuments,
|
|
];
|
|
return route.fulfill({ status: 204 });
|
|
});
|
|
|
|
void page.route(
|
|
/\/api\/projects\/([^/]+)\/documents\/from-thread$/,
|
|
(route) => {
|
|
if (route.request().method() !== "POST") {
|
|
return route.fallback();
|
|
}
|
|
const projectId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-3) ?? "",
|
|
);
|
|
if (!findProject(projectId)) {
|
|
return route.fulfill(notFound("Project not found"));
|
|
}
|
|
const body = route.request().postDataJSON() as {
|
|
thread_id: string;
|
|
kind: "upload" | "output";
|
|
name: string;
|
|
shelf_name?: string;
|
|
};
|
|
projectDocumentSequence += 1;
|
|
const document: MockProjectDocument = {
|
|
id: `mock-doc-${projectDocumentSequence}`,
|
|
project_id: projectId,
|
|
name: body.shelf_name ?? body.name,
|
|
size_bytes: 128,
|
|
source_thread_id: body.thread_id,
|
|
source_kind: body.kind,
|
|
source_name: body.name,
|
|
};
|
|
projectDocuments = [...projectDocuments, document];
|
|
return route.fulfill(
|
|
json(201, {
|
|
document: projectDocumentResponse(document),
|
|
deduplicated: false,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
void page.route(
|
|
/\/api\/projects\/([^/]+)\/documents\/([^/]+)\/attach-to-thread\/([^/]+)$/,
|
|
(route) => {
|
|
if (route.request().method() !== "POST") {
|
|
return route.fallback();
|
|
}
|
|
const parts = new URL(route.request().url()).pathname.split("/");
|
|
const projectId = decodeURIComponent(parts.at(-5) ?? "");
|
|
const documentId = decodeURIComponent(parts.at(-3) ?? "");
|
|
const threadId = decodeURIComponent(parts.at(-1) ?? "");
|
|
const document = projectDocuments.find(
|
|
(candidate) =>
|
|
candidate.id === documentId && candidate.project_id === projectId,
|
|
);
|
|
if (!document || !threads.some((t) => t.thread_id === threadId)) {
|
|
return route.fulfill(notFound("Project document not found"));
|
|
}
|
|
return route.fulfill(
|
|
json(200, {
|
|
filename: document.name,
|
|
size_bytes: document.size_bytes,
|
|
virtual_path: `/mnt/user-data/uploads/${document.name}`,
|
|
artifact_url: `/api/threads/${threadId}/artifacts/mnt/user-data/uploads/${encodeURIComponent(document.name)}`,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
void page.route(/\/api\/projects\/([^/]+)\/thread-files(?:\?|$)/, (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
const projectId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
if (!findProject(projectId)) {
|
|
return route.fulfill(notFound("Project not found"));
|
|
}
|
|
const projectThreadIds = new Set(
|
|
threads
|
|
.filter(
|
|
(thread) =>
|
|
thread.metadata?.[THREAD_PROJECT_METADATA_KEY] === projectId,
|
|
)
|
|
.map((thread) => thread.thread_id),
|
|
);
|
|
const groups = threadFileGroups
|
|
.filter((group) => projectThreadIds.has(group.thread_id))
|
|
.map((group) => ({ truncated: false, ...group }));
|
|
return route.fulfill(
|
|
json(200, {
|
|
groups,
|
|
next_offset: null,
|
|
truncated: groups.some((group) => group.truncated),
|
|
}),
|
|
);
|
|
});
|
|
|
|
void page.route(/\/api\/trash\/documents(?:\?|$)/, (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
const search = new URL(route.request().url()).searchParams;
|
|
const limit = Number(search.get("limit") ?? 100);
|
|
const offset = Number(search.get("offset") ?? 0);
|
|
const all = trashDocuments.map((document) => ({
|
|
...projectDocumentResponse(document),
|
|
trashed_at: document.trashed_at,
|
|
trash_origin: document.trash_origin ?? null,
|
|
}));
|
|
return route.fulfill(
|
|
json(200, {
|
|
documents: all.slice(offset, offset + limit),
|
|
total: all.length,
|
|
limit,
|
|
offset,
|
|
}),
|
|
);
|
|
});
|
|
|
|
void page.route(/\/api\/trash\/documents\/([^/]+)\/restore$/, (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
return route.fallback();
|
|
}
|
|
const documentId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
const document = trashDocuments.find(
|
|
(candidate) => candidate.id === documentId,
|
|
);
|
|
if (!document) {
|
|
return route.fulfill(notFound("Trash document not found"));
|
|
}
|
|
let target: string | undefined;
|
|
try {
|
|
const body = route.request().postDataJSON() as {
|
|
project_id?: string | null;
|
|
};
|
|
target = body.project_id ?? undefined;
|
|
} catch {
|
|
target = undefined;
|
|
}
|
|
if (!target) {
|
|
const originId = document.trash_origin?.project_id;
|
|
const origin = originId ? findProject(originId) : undefined;
|
|
if (origin && (origin.status ?? "active") === "active") {
|
|
target = origin.id;
|
|
}
|
|
}
|
|
const targetProject = target ? findProject(target) : undefined;
|
|
if (!targetProject || (targetProject.status ?? "active") !== "active") {
|
|
return route.fulfill(notFound("Trash document not found"));
|
|
}
|
|
trashDocuments = trashDocuments.filter(
|
|
(candidate) => candidate.id !== documentId,
|
|
);
|
|
const restored: MockProjectDocument = {
|
|
...document,
|
|
project_id: targetProject.id,
|
|
};
|
|
projectDocuments = [...projectDocuments, restored];
|
|
return route.fulfill(
|
|
json(200, {
|
|
outcome: "restored",
|
|
document: projectDocumentResponse(restored),
|
|
}),
|
|
);
|
|
});
|
|
|
|
void page.route(/\/api\/trash\/documents\/([^/]+)\/purge$/, (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
return route.fallback();
|
|
}
|
|
const documentId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
if (!trashDocuments.some((candidate) => candidate.id === documentId)) {
|
|
return route.fulfill(notFound("Trash document not found"));
|
|
}
|
|
trashDocuments = trashDocuments.filter(
|
|
(candidate) => candidate.id !== documentId,
|
|
);
|
|
return route.fulfill({ status: 204 });
|
|
});
|
|
|
|
void page.route(/\/api\/trash\/purge$/, (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
return route.fallback();
|
|
}
|
|
const purged = trashDocuments.length;
|
|
trashDocuments = [];
|
|
return route.fulfill(json(200, { purged }));
|
|
});
|
|
|
|
void page.route(/\/api\/threads\/[^/]+\/move$/, (route) => {
|
|
if (route.request().method() === "POST") {
|
|
const threadId = decodeURIComponent(
|
|
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
|
);
|
|
const body = route.request().postDataJSON() as {
|
|
project_id?: string | null;
|
|
};
|
|
const updated = patchThreadMetadata(threadId, {
|
|
[THREAD_PROJECT_METADATA_KEY]: body.project_id ?? null,
|
|
});
|
|
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)),
|
|
});
|
|
}
|
|
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 sourceSequence =
|
|
sourceThread?.metadata?.deerflow_branch === true &&
|
|
Number.isSafeInteger(sourceThread.metadata.branch_title_sequence) &&
|
|
Number(sourceThread.metadata.branch_title_sequence) >= 2 &&
|
|
Number(sourceThread.metadata.branch_title_sequence) <
|
|
Number.MAX_SAFE_INTEGER
|
|
? Number(sourceThread.metadata.branch_title_sequence)
|
|
: undefined;
|
|
const sequence = sourceSequence === undefined ? 2 : sourceSequence + 1;
|
|
const sourceSuffix = sourceSequence ? ` (${sourceSequence})` : undefined;
|
|
const baseTitle =
|
|
sourceSuffix && sourceTitle?.endsWith(sourceSuffix)
|
|
? sourceTitle.slice(0, -sourceSuffix.length).trimEnd()
|
|
: sourceTitle;
|
|
const title =
|
|
body.title ??
|
|
(baseTitle
|
|
? `${baseTitle.slice(0, 256 - ` (${sequence})`.length).trimEnd()} (${sequence})`
|
|
: undefined);
|
|
|
|
upsertThread({
|
|
thread_id: MOCK_THREAD_ID_2,
|
|
title,
|
|
updated_at: new Date().toISOString(),
|
|
metadata: {
|
|
deerflow_branch: true,
|
|
...(!body.title && title ? { branch_title_sequence: sequence } : {}),
|
|
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();
|
|
});
|
|
|
|
// Token usage — the chat header polls this per thread. Without a mock the
|
|
// request falls through to a gateway that is not running under Playwright,
|
|
// and a 401 there redirects the whole page to /login mid-test.
|
|
void page.route("**/api/threads/*/token-usage", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
const threadId = /\/api\/threads\/([^/]+)\/token-usage/.exec(
|
|
route.request().url(),
|
|
)?.[1];
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
thread_id: threadId ?? "unknown",
|
|
total_tokens: 0,
|
|
total_input_tokens: 0,
|
|
total_output_tokens: 0,
|
|
total_runs: 0,
|
|
by_model: {},
|
|
by_caller: { lead_agent: 0, subagent: 0, middleware: 0 },
|
|
context_usage: null,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// MCP background tasks — same fallthrough-to-401 problem as token-usage.
|
|
void page.route("**/api/threads/*/mcp-tasks*", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify([]),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Workspace changes — the run-scoped badge query. Unmocked it 401s against
|
|
// the absent gateway and the fetcher redirects the page to /login.
|
|
void page.route("**/api/threads/*/runs/*/workspace-changes*", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
run_id: "mock-run",
|
|
thread_id: "mock-thread",
|
|
status: "success",
|
|
summary: {
|
|
created: 0,
|
|
modified: 0,
|
|
deleted: 0,
|
|
symlink_created: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
truncated: false,
|
|
},
|
|
changes: [],
|
|
}),
|
|
});
|
|
}
|
|
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) => {
|
|
const url = new URL(route.request().url());
|
|
const threadId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
|
|
const matchingThread = threads.find(
|
|
(thread) => thread.thread_id === threadId,
|
|
);
|
|
|
|
if (route.request().method() === "GET") {
|
|
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",
|
|
}),
|
|
});
|
|
}
|
|
if (route.request().method() === "POST") {
|
|
const body = route.request().postDataJSON() as {
|
|
values?: { title?: unknown };
|
|
};
|
|
const updated =
|
|
typeof body.values?.title === "string"
|
|
? patchThreadTitle(threadId, body.values.title)
|
|
: 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({
|
|
configurable: {
|
|
thread_id: threadId,
|
|
checkpoint_ns: "",
|
|
checkpoint_id: "mock-checkpoint",
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
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 =
|
|
options?.runStreamHandler ??
|
|
((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 },
|
|
mcp_tasks: { enabled: featureFlags.mcpTasksEnabled },
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/mcp/config", (route) =>
|
|
route.fulfill({ json: { mcp_servers: {} } }),
|
|
);
|
|
|
|
// Skills list — capability center 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",
|
|
generation: "config-generation",
|
|
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.",
|
|
generation: "config-generation",
|
|
status: larkIntegrationStatus,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/integrations/lark/config/credentials", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
larkIntegrationStatus = {
|
|
...larkIntegrationStatus,
|
|
app_configured: true,
|
|
app_id: "cli_switched_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 app switched. Reconnect to authorize the new app.",
|
|
generation: "switch-generation",
|
|
status: larkIntegrationStatus,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route("**/api/integrations/lark/auth/start", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
const request = route.request().postDataJSON() as {
|
|
generation?: string;
|
|
};
|
|
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",
|
|
generation: request.generation ?? "auth-generation",
|
|
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[],
|
|
options?: {
|
|
responseMessage?: Record<string, unknown>;
|
|
messageMetadata?: Record<string, unknown>;
|
|
},
|
|
) {
|
|
const threadId = runStreamThreadId(route);
|
|
const responseMessage = options?.responseMessage ?? {
|
|
type: "ai",
|
|
id: "msg-ai-1",
|
|
content: "Hello from DeerFlow!",
|
|
};
|
|
const events = [
|
|
{
|
|
event: "metadata",
|
|
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
|
|
},
|
|
...(options?.messageMetadata
|
|
? [
|
|
{
|
|
event: "messages",
|
|
data: [responseMessage, options.messageMetadata],
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
event: "values",
|
|
data: {
|
|
...values,
|
|
messages: mockStreamMessages(route, inputMessages, responseMessage),
|
|
},
|
|
},
|
|
{ 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,
|
|
});
|
|
}
|