Totoro 0baa99ca90
feat(models): add scoped model controls to Web chats (#7249)
* test: stabilize reporter evidence and guard missing chat nodes

* feat(models): scope Web model choices to tasks, turns and conversations

* test(e2e): mock model-control preview for ordinary chat submissions

* fix(models): harden scoped choices and stale Agent submissions

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-22 08:08:23 +08:00

2830 lines
89 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 { readFileSync } from "node:fs";
import path from "node:path";
import type { Page, Route } from "@playwright/test";
import type { GoalState, ThreadGoalOutcome } from "@/core/threads/types";
// ---------------------------------------------------------------------------
// 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";
/**
* The keys the Gateway's POST /history head may carry, from the goal contract
* the backend router test also reads (`contracts/thread_goal_contract.json`).
*/
const HISTORY_HEAD_KEYS: readonly string[] = (
JSON.parse(
readFileSync(
path.resolve(process.cwd(), "../contracts/thread_goal_contract.json"),
"utf8",
),
) as { history_head_keys: string[] }
).history_head_keys;
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?: GoalState | Record<string, unknown> | null;
/**
* The record of a met goal (`mockGoalOutcome`). Runs keep it, like the
* backend; PUT and DELETE /goal drop it.
*/
goal_outcome?: ThreadGoalOutcome | null;
/** Thread search `unread` (a server-originated run changed since it was opened); default false. */
unread?: boolean;
};
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[];
honorRequestedThreadId?: boolean;
agents?: MockAgent[];
skills?: MockSkill[];
scheduledTasks?: MockScheduledTask[];
/** Initial run history per task id (newest first), served by the runs route. */
scheduledTaskRuns?: Record<string, MockScheduledTaskRun[]>;
/** Lifecycle events per originating chat id, served by `/api/threads/{id}/scheduled-task-events`. */
scheduledTaskEvents?: Record<string, MockScheduledTaskEvent[]>;
/**
* `/api/channels/providers` entries (the list is empty and disabled when
* omitted). `proactive_notifications` defaults to true only for `wecom`.
*/
channelProviders?: MockChannelProvider[];
uploadLimits?: {
max_files: number;
max_file_size: number;
max_total_size: number;
};
features?: {
agentsApiEnabled?: boolean;
browserControlEnabled?: boolean;
mcpTasksEnabled?: boolean;
knowledgeScopeSelectionEnabled?: boolean;
/** Existing task flags default to true; optional group notifications default to false. */
scheduledTasks?: {
available?: boolean;
running?: boolean;
toolEnabled?: boolean;
groupNotifications?: boolean;
};
/** Emitted as `thread_activity`; unavailable unless enabled, so nothing polls by default. */
threadActivity?: {
available?: boolean;
};
};
runStreamHandler?: (route: Route) => Promise<void>;
};
/** One `/api/threads/{id}/scheduled-task-events` row; omitted fields are null. */
export type MockScheduledTaskEvent = {
id: string;
task_id: string;
event: "task_stopped" | "task_paused" | "task_finished";
reason_code: string;
task_title?: string | null;
stop_condition?: string | null;
run_thread_id?: string | null;
run_number?: number | null;
run_status?: MockScheduledTaskRun["status"] | null;
max_runs?: number | null;
end_at?: string | null;
schedule_type?: "once" | "cron" | "interval" | null;
after_run_id?: string | null;
created_at: string;
};
export type MockChannelProvider = {
provider: string;
display_name: string;
enabled?: boolean;
configured?: boolean;
connection_status?: string;
proactive_notifications?: boolean;
};
/** One server-originated run change reported by the mocked `/api/thread-activity`. */
export type MockThreadActivityEntry = {
thread_id: string;
/** Default `"schedule"`; `null` is the caller's own interactive run (never listed). */
origin_kind?: "schedule" | "im_channel" | "github" | "extension" | null;
status?: string;
};
/** Handles a spec can use to drive the mocked backend after `mockLangGraphAPI`. */
export type MockAPIController = {
/** Add or replace a thread in the thread search results. */
upsertThread: (thread: MockThread) => void;
/** A thread as the mock now stores it, e.g. the goal a PUT /goal saved. */
getThread: (threadId: string) => MockThread | undefined;
/**
* While on, PUT and DELETE /goal return the Gateway's 409 for a thread
* with a run in flight.
*/
setGoalWritesBusy: (busy: boolean) => void;
/** Record run changes; the next activity poll after the seed returns them. */
pushThreadActivity: (...entries: MockThreadActivityEntry[]) => void;
/** Simulate a read on another device (raises `read_version`). */
bumpReadVersion: () => void;
/**
* Replace the scheduled tasks every task route serves (the list, one task,
* a chat's tasks), e.g. to flip a task to paused or delete it.
*/
setScheduledTasks: (tasks: MockScheduledTask[]) => void;
/** Replace the lifecycle events of an originating chat. */
setScheduledTaskEvents: (
threadId: string,
events: MockScheduledTaskEvent[],
) => void;
/** Thread ids of every `POST /api/threads/{id}/read`, in order. */
readonly readRequests: readonly string[];
};
function scheduledTaskEventResponse(event: MockScheduledTaskEvent) {
return {
task_title: null,
stop_condition: null,
run_thread_id: null,
run_number: null,
run_status: null,
max_runs: null,
end_at: null,
schedule_type: null,
after_run_id: null,
...event,
};
}
export type MockScheduledTask = {
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;
goal_objective?: string | null;
max_runs?: number | null;
end_at?: string | null;
origin_thread_id?: string | null;
standing_notes?: string[] | null;
stop_condition?: string | null;
notification_group_connection_id?: string | null;
automatic_runs_used?: number;
active_run_status?: "queued" | "launching" | "running" | null;
created_at: string;
updated_at: string;
};
export type MockScheduledTaskRun = {
id: string;
task_id: string;
thread_id: string | null;
run_id: string | null;
scheduled_for: string;
trigger: "scheduled" | "manual";
status:
| "queued"
| "launching"
| "running"
| "success"
| "unmet"
| "failed"
| "skipped"
| "interrupted";
error: string | null;
goal_objective?: string | null;
goal_verdict?: Record<string, unknown> | null;
stop_requested_run_id?: string | null;
attempt_count: number;
started_at: string | null;
finished_at: string | null;
created_at: string;
run_number?: number | null;
total_tokens?: number | null;
summary?: string | null;
};
/** Fill the response fields newer backends always send, so older fixtures stay valid. */
function scheduledTaskResponse(task: MockScheduledTask) {
return {
context_mode: "fresh_thread_per_run",
last_thread_id: null,
assistant_id: null,
goal_objective: null,
max_runs: null,
end_at: null,
origin_thread_id: null,
standing_notes: [],
stop_condition: null,
automatic_runs_used: 0,
active_run_status: null,
...task,
thread_id: task.thread_id ?? null,
};
}
function scheduledTaskNotFound(route: Route) {
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
detail: { code: "task_not_found", message: "Scheduled task not found" },
}),
});
}
const CLEARABLE_TASK_FIELDS = [
"goal_objective",
"max_runs",
"end_at",
"stop_condition",
"notification_group_connection_id",
] as const;
/** PATCH/resume semantics: a present key replaces the value; `null` clears the clearable fields. */
function applyClearableTaskFields(
task: MockScheduledTask,
payload: Record<string, unknown>,
): MockScheduledTask {
const next: MockScheduledTask = { ...task };
for (const key of CLEARABLE_TASK_FIELDS) {
if (!(key in payload)) continue;
const value = payload[key];
if (value === null || value === "") {
next[key] = null;
} else if (key === "max_runs" && typeof value === "number") {
next.max_runs = value;
} else if (key !== "max_runs" && typeof value === "string") {
next[key] = value;
}
}
return next;
}
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;
}
/** The visible messages a run stream request submits. */
export 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,
];
}
/** The thread a run stream request targets (path first, then the body). */
export 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;
}
}
/** The backend's `is_active_goal`: only an active goal is projected or met. */
function isActiveGoal(goal: MockThread["goal"]): goal is GoalState {
return goal?.status === "active";
}
/** An active goal as PUT /goal stores it; `overrides` sets counts or a check. */
export function mockGoal(
objective: string,
overrides: Partial<GoalState> = {},
): GoalState {
const now = new Date().toISOString();
return {
objective,
status: "active",
created_at: now,
updated_at: now,
continuation_count: 0,
max_continuations: 8,
no_progress_count: 0,
max_no_progress_continuations: 2,
...overrides,
};
}
/**
* The record the backend writes when it clears a met goal
* (runtime/goal.py `build_goal_outcome`), anchored to `replyMessageId`.
*/
export function mockGoalOutcome(
goal: Pick<
GoalState,
"objective" | "created_at" | "continuation_count" | "max_continuations"
>,
replyMessageId: string | null,
overrides: Partial<ThreadGoalOutcome> = {},
): ThreadGoalOutcome {
return {
status: "achieved",
objective: goal.objective,
goal_created_at: goal.created_at,
achieved_at: new Date().toISOString(),
continuation_count: goal.continuation_count,
max_continuations: goal.max_continuations,
reason: "The reply meets the goal.",
relied_on_assumption: false,
reply_message_id: replyMessageId,
...overrides,
};
}
// ---------------------------------------------------------------------------
// 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,
): MockAPIController {
void page.route("**/api/plugins", (route) => route.fulfill({ json: [] }));
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: MockScheduledTask[] = [...scheduledTasks];
const mutableTaskRuns: Record<string, MockScheduledTaskRun[]> =
Object.fromEntries(
Object.entries(options?.scheduledTaskRuns ?? {}).map(([id, runs]) => [
id,
[...runs],
]),
);
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"
| "broker",
sandbox_runtime_probed: true,
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,
knowledgeScopeSelectionEnabled:
options?.features?.knowledgeScopeSelectionEnabled ?? false,
scheduledTasks: {
available: options?.features?.scheduledTasks?.available ?? true,
running: options?.features?.scheduledTasks?.running ?? true,
toolEnabled: options?.features?.scheduledTasks?.toolEnabled ?? true,
groupNotifications:
options?.features?.scheduledTasks?.groupNotifications ?? false,
},
threadActivity: {
available: options?.features?.threadActivity?.available ?? false,
},
};
// Thread activity feed: a run-change log ordered by `seq`, plus the
// per-user read clock. Cursors are "<seq>:<run id>".
const activityLog: Array<
Required<MockThreadActivityEntry> & { seq: number }
> = [];
let activitySeq = 100;
let readVersion = 0;
const readRequests: string[] = [];
let goalWritesBusy = false;
const scheduledTaskEvents: Record<string, MockScheduledTaskEvent[]> = {
...(options?.scheduledTaskEvents ?? {}),
};
const upsertThread = (thread: MockThread) => {
threads = [
thread,
...threads.filter((existing) => existing.thread_id !== thread.thread_id),
];
};
// The materialized thread state. Like a checkpoint, it has no `goal` key
// before a goal is set or after a clear, and `goal_outcome` only once met.
const threadStateValues = (thread: MockThread): Record<string, unknown> => ({
title: thread.title ?? "Untitled",
messages: thread.messages ?? [
{
type: "human",
id: `msg-human-${thread.thread_id}`,
content: [{ type: "text", text: "Previous question" }],
},
{
type: "ai",
id: `msg-ai-${thread.thread_id}`,
content: `Response in thread ${thread.title ?? thread.thread_id}`,
},
],
artifacts: thread.artifacts ?? [],
...(thread.goal ? { goal: thread.goal } : {}),
...(thread.goal_outcome ? { goal_outcome: thread.goal_outcome } : {}),
});
// The POST /history head: only the contract's `history_head_keys`, with
// `goal` only while active and `goal_outcome` only when achieved (threads.py
// get_thread_history). `artifacts` is mock-only: the real head lacks it,
// but the artifact specs preload their artifacts through it.
const historyHeadValues = (thread: MockThread) => {
const state = threadStateValues(thread);
const projected = HISTORY_HEAD_KEYS.filter(
(key) =>
key in state &&
(key !== "goal" || isActiveGoal(thread.goal)) &&
(key !== "goal_outcome" || thread.goal_outcome?.status === "achieved"),
);
return {
...Object.fromEntries(projected.map((key) => [key, state[key]])),
artifacts: state.artifacts,
};
};
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 },
unread: thread.unread ?? false,
});
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") {
const providers = (options?.channelProviders ?? []).map((provider) => ({
enabled: true,
configured: true,
connectable: true,
unavailable_reason: null,
auth_mode: "token",
connection_status: "not_connected",
credential_fields: [],
credential_values: {},
proactive_notifications: provider.provider === "wecom",
...provider,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: providers.length > 0,
providers,
}),
});
}
return route.fallback();
});
// Thread activity feed (`GET /api/thread-activity`): 503 when unavailable,
// like a memory-backed Gateway; no cursor seeds; a cursor returns the
// logged changes after it (deduped by thread, latest last).
void page.route(/\/api\/thread-activity(?:\?|$)/, (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
if (!featureFlags.threadActivity.available) {
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "Thread activity is not available" }),
});
}
const cursor = new URL(route.request().url()).searchParams.get("cursor");
if (cursor === null) {
const head = activityLog.at(-1);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cursor: head ? `${head.seq}:run-${head.seq}` : "0:",
threads: [],
truncated: false,
read_version: readVersion,
}),
});
}
const match = /^(\d+):/.exec(cursor);
if (!match) {
return route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
detail: {
code: "invalid_cursor",
message: "Invalid activity cursor",
},
}),
});
}
const after = Number(match[1]);
const changed = activityLog.filter((entry) => entry.seq > after);
const last = changed.at(-1);
const latest = new Map<string, (typeof changed)[number]>();
for (const entry of changed) {
latest.delete(entry.thread_id);
latest.set(entry.thread_id, entry);
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cursor: last ? `${last.seq}:run-${last.seq}` : cursor,
threads: [...latest.values()]
.filter((entry) => entry.origin_kind !== null)
.map(({ thread_id, origin_kind, status }) => ({
thread_id,
origin_kind,
status,
})),
truncated: false,
read_version: readVersion,
}),
});
});
// Mark read (`POST /api/threads/{id}/read`): raises `read_version` only
// when the thread was unread, like the Gateway.
void page.route(/\/api\/threads\/[^/]+\/read$/, (route) => {
if (route.request().method() !== "POST") {
return route.fallback();
}
const parts = new URL(route.request().url()).pathname.split("/");
const threadId = decodeURIComponent(parts.at(-2) ?? "");
readRequests.push(threadId);
const thread = threads.find((item) => item.thread_id === threadId);
if (!thread) {
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
});
}
if (thread.unread) {
threads = threads.map((item) =>
item.thread_id === threadId ? { ...item, unread: false } : item,
);
readVersion += 1;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ unread: false, read_version: readVersion }),
});
});
// Lifecycle events of an originating chat. Rows outlive their task, so
// this route never looks at the task list.
void page.route(
/\/api\/threads\/[^/]+\/scheduled-task-events(?:\?|$)/,
(route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
const parts = new URL(route.request().url()).pathname.split("/");
const threadId = decodeURIComponent(parts.at(-2) ?? "");
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
events: (scheduledTaskEvents[threadId] ?? []).map(
scheduledTaskEventResponse,
),
}),
});
},
);
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(scheduledTaskResponse)),
});
}
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: MockScheduledTask = {
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",
};
const stored = applyClearableTaskFields(created, payload);
mutableScheduledTasks = [stored, ...mutableScheduledTasks];
mutableTaskRuns[stored.id] = [];
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(scheduledTaskResponse(stored)),
});
}
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, active_run_status: null }
: task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
if (!task) return scheduledTaskNotFound(route);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(scheduledTaskResponse(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) ?? "",
);
// Optional renewal body: `max_runs` / `end_at`, `null` clears that cap.
const renewal = (() => {
try {
return (route.request().postDataJSON() ?? {}) as Record<
string,
unknown
>;
} catch {
return {};
}
})();
mutableScheduledTasks = mutableScheduledTasks.map((task) =>
task.id === taskId
? {
...applyClearableTaskFields(task, {
...("max_runs" in renewal
? { max_runs: renewal.max_runs }
: {}),
...("end_at" in renewal ? { end_at: renewal.end_at } : {}),
}),
status: "enabled" as const,
}
: task,
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
if (!task) return scheduledTaskNotFound(route);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(scheduledTaskResponse(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) return scheduledTaskNotFound(route);
const threadId = task.thread_id ?? `trial-thread-${taskId}`;
const runId = `run-${taskId}`;
mutableTaskRuns[taskId] = [
{
id: `task-run-${taskId}`,
task_id: taskId,
thread_id: threadId,
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,
outcome: "launched",
existing: false,
thread_id: threadId,
}),
});
}
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*", (route) => {
const request = route.request();
if (request.method() === "GET") {
const taskId = decodeURIComponent(
new URL(request.url()).pathname.split("/").at(-1) ?? "",
);
const task = mutableScheduledTasks.find((item) => item.id === taskId);
if (!task) return scheduledTaskNotFound(route);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(scheduledTaskResponse(task)),
});
}
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: MockScheduledTask | undefined;
mutableScheduledTasks = mutableScheduledTasks.map((task) => {
if (task.id !== taskId) {
return task;
}
updated = {
...applyClearableTaskFields(task, payload),
...(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;
});
if (!updated) return scheduledTaskNotFound(route);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(scheduledTaskResponse(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.flatMap((task) => {
const runInThread = (mutableTaskRuns[task.id] ?? []).find(
(run) => run.thread_id === threadId,
);
const relation =
task.origin_thread_id === threadId
? "origin"
: task.thread_id === threadId
? "reuse"
: runInThread
? "run"
: null;
if (!relation) return [];
return [
{
...scheduledTaskResponse(task),
thread_relation: relation,
thread_run:
relation === "run" && runInThread
? {
run_number: runInThread.run_number ?? null,
trigger: runInThread.trigger,
scheduled_for: runInThread.scheduled_for,
status: runInThread.status,
}
: 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") {
const threadId =
(options?.honorRequestedThreadId
? (route.request().postDataJSON() as { thread_id?: string } | null)
?.thread_id
: undefined) ?? MOCK_THREAD_ID;
// Like the Gateway, a repeat create of a requested id returns the
// thread as it is, e.g. with the goal a /goal command saved first.
const existing = options?.honorRequestedThreadId
? threads.find((thread) => thread.thread_id === threadId)
: undefined;
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: "New Chat",
updated_at: new Date().toISOString(),
messages: options?.createdThreadMessages ?? mockStreamMessages(),
});
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: {},
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 (
goalWritesBusy &&
(route.request().method() === "PUT" ||
route.request().method() === "DELETE")
) {
const action = route.request().method() === "PUT" ? "Set" : "Clear";
return route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
detail: `Thread has a run in flight. ${action} the goal after the run finishes.`,
}),
});
}
// Every goal write also drops the met record (runtime/goal.py
// write_thread_goal), so an older "Goal met" cannot come back.
if (route.request().method() === "DELETE") {
if (matchingThread) {
matchingThread.goal = null;
matchingThread.goal_outcome = 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 = mockGoal(payload.objective ?? "");
matchingThread ??= {
thread_id: threadId,
title: "New Chat",
updated_at: new Date().toISOString(),
};
upsertThread({ ...matchingThread, goal, goal_outcome: null });
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: historyHeadValues(matchingThread),
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: matchingThread
? threadStateValues(matchingThread)
: { title: "Untitled", messages: [], 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;
const goalOutcome = existingThread?.goal_outcome ?? null;
upsertThread({
thread_id: threadId,
title: threadId === MOCK_SIDECAR_THREAD_ID ? "Side chat" : "New Chat",
updated_at: new Date().toISOString(),
goal,
goal_outcome: goalOutcome,
metadata: existingThread?.metadata,
messages: mockStreamMessages(route),
});
// Values frames carry every set channel and no key for an unset one.
return handleRunStream(route, {
...(goal ? { goal } : {}),
...(goalOutcome ? { goal_outcome: goalOutcome } : {}),
});
});
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
void page.route(
"**/api/langgraph/threads/*/runs/stream",
handleMockRunStream,
);
// Models list — model picker dropdown
// Agent settings also read these catalogs. Keep ordinary UI specs independent
// of any real Gateway listening locally; catalog-specific specs override them.
void page.route("**/api/subagents", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: { subagents: [] } })
: route.fallback(),
);
void page.route("**/api/agent-tool-groups", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: { tool_groups: [] } })
: route.fallback(),
);
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();
});
// General UI specs do not choose a model. Model-control specs can override
// this preview just as they override the empty model list above.
void page.route("**/api/models/resolve", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
json: {
status: "none",
scope: null,
query: "",
model_name: null,
candidates: [],
task_offset: 0,
},
});
}
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 },
knowledge_base: {
scope_selection_enabled:
featureFlags.knowledgeScopeSelectionEnabled,
},
scheduled_tasks: {
available: featureFlags.scheduledTasks.available,
running: featureFlags.scheduledTasks.running,
tool_enabled: featureFlags.scheduledTasks.toolEnabled,
group_notifications: featureFlags.scheduledTasks.groupNotifications,
min_interval_seconds: 60,
},
thread_activity: {
available: featureFlags.threadActivity.available,
},
}),
});
}
return route.fallback();
});
void page.route("**/api/knowledge/capabilities?*", (route) =>
route.fulfill({
json: {
operations: featureFlags.knowledgeScopeSelectionEnabled
? [
"search",
"sources",
"catalog",
"documents",
"dataset_filter",
"document_filter",
]
: ["search"],
scope_selection_enabled: featureFlags.knowledgeScopeSelectionEnabled,
},
}),
);
void page.route("**/api/mcp/config", (route) =>
route.fulfill({ json: { mcp_servers: {} } }),
);
void page.route("**/api/mcp/personal/config", (route) =>
route.fulfill({ json: { mcp_servers: {} } }),
);
// Skills list — capability center and slash autocomplete
void page.route("**/api/capabilities/catalog", (route) =>
route.fulfill({
contentType: "application/json",
body: readFileSync(
path.resolve(
process.cwd(),
"../backend/packages/harness/deerflow/capabilities/builtin.json",
),
"utf8",
),
}),
);
void page.route("**/api/capabilities/installations/*", (route) => {
const adapter = new URL(route.request().url()).pathname.split("/").pop();
const items =
adapter === "lark"
? [
{
id: "lark",
plugin_id: "lark",
adapter: "lark",
name: "Lark / Feishu",
reference: "lark",
installed: larkIntegrationStatus.installed,
enabled: null,
version: null,
auth_status: "required",
health: "unknown",
scope: "user",
category: null,
icon: null,
},
]
: adapter === "skills"
? skills.map((skill) => ({
id: `skill:${skill.category ?? "public"}:${skill.name}`,
plugin_id: null,
adapter: "skills",
name: skill.name,
reference: skill.name,
description: skill.description,
installed: true,
enabled: skill.enabled ?? true,
version: null,
auth_status: "not_required",
health: "unknown",
scope: "deployment",
category: skill.category,
icon: null,
}))
: [];
return route.fulfill({ json: { items, can_manage: true } });
});
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_probed: true,
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" }),
});
});
return {
upsertThread,
getThread: (threadId) =>
threads.find((thread) => thread.thread_id === threadId),
setGoalWritesBusy: (busy) => {
goalWritesBusy = busy;
},
pushThreadActivity: (...entries) => {
for (const entry of entries) {
activitySeq += 1;
activityLog.push({
seq: activitySeq,
thread_id: entry.thread_id,
// Server-originated unless a spec passes `null` (an interactive run).
origin_kind:
entry.origin_kind === undefined ? "schedule" : entry.origin_kind,
status: entry.status ?? "success",
});
}
},
bumpReadVersion: () => {
readVersion += 1;
},
setScheduledTasks: (tasks) => {
mutableScheduledTasks = [...tasks];
},
setScheduledTaskEvents: (threadId, events) => {
scheduledTaskEvents[threadId] = events;
},
get readRequests() {
return readRequests;
},
};
}
// ---------------------------------------------------------------------------
// 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,
});
}