Totoro 0fe6e63a68
feat(goal): show why a chat goal stopped and when it was met, and redact the goal evaluator and continuation input (#6556)
* feat(goal): record the met goal and keep goal state on the history head

- Add a goal_outcome LastValue channel (GoalOutcomeState). Only the
  guarded clear of a satisfied ordinary goal writes it, in the same
  checkpoint as the clear; the reply anchor is the latest visible AI
  message with text, the reply the visible signature keys on. A goal
  stored without created_at still clears and gets a record.
- write_thread_goal removes the record on every other goal write and
  lists it in channel_versions, new_versions and metadata writes when
  it changes. Threads that never had a record keep their checkpoint
  shape. The sync-only SqliteSaver and CachedHistorySaver, which take
  the #6448 sync fallback, store and drop it the same way.
- goal_outcome is server-owned: POST /state and non-internal run input
  that carry it get 400. A POST /state goal write, or a goal in
  external run input, clears it.
- A branch head written onto its replay base resets the channels the
  head lacks, so a goal or record that write_thread_goal removed does
  not come back. Branching from the met turn keeps the record without
  the met goal.
- The POST /history head now carries an active goal and an achieved
  goal_outcome, also before any message exists, so the post-run
  history refetch keeps them. Older entries stay title/thread_data.
- contracts/thread_goal_contract.json pins the chat stand-down and
  check-failure codes, the record shape and status, the head keys and
  the host-written reasons.

* feat(frontend): show why a goal stopped and when it was met

- The goal bar derives its state from the active goal, the goal_outcome
  record and the messages: set, in progress, continuing, stopped (limit,
  auto-continue off, no progress, token budget, missing evidence, failed
  run, unknown code), waiting for you, waiting, couldn't check the goal,
  paused and goal met. An 8/8 stand-down now reads "Stopped" with the
  next step instead of "Continuing 8/8", and a count-0 stand-down no
  longer looks like a fresh goal.
- "Goal met" shows while its anchor reply is the latest visible message
  (a visible human message, or a visible AI message with text, moves the
  chat on), so a reload agrees with the live view. An active goal wins
  over the record, and this tab's /goal set or clear hides it.
- useActiveGoal drops the optimistic goal when the record of that same
  goal arrives, which fixes the bar that stayed after a goal met on the
  first run (the clear frame has no goal key), also for a goal stored
  without created_at. Server goal values pass through isGoalState; a
  /goal status read uses the backend's own active check, so the bar, the
  toast and the edit lock agree with the server.
- Row 2 gives the reason and what to do next; Details holds the full
  goal, the checker's own note (never host-written text, a check failure
  or a paused goal's older check), the assumption note and an unknown
  code, and starts collapsed in each new state. The bar is a labelled
  section with a live region that announces state changes only (another
  goal's same state counts) and re-reads its text after a language switch.
- The edit pencil on the latest turn is shown disabled with "Editing is
  off while a goal is set" instead of hidden; the message toolbar shows
  on focus-within, so keyboard users can reach it.
- A /goal set or clear rejected with 409 keeps the draft and says a run
  is still going; other failures keep the server's message and the draft.
  A /goal request replaced by a newer one leaves the composer alone, so
  a double submit no longer empties it before the second one fails.
- Copy lives in inputBox.goalBar (en-US, zh-CN); a contract test reads
  contracts/thread_goal_contract.json, and the raw-identifier guard now
  catches stand-down codes and goal field names.

* test(frontend): cover the goal bar on both chat pages

- tests/e2e/goal-status.spec.ts runs every case on the chat page and the
  agent chat page, since each wires the goal bar and the edit lock itself:
  a goal stopped at 8/8, one waiting for details and one waiting on an
  open question card show their reason and next step, also after a
  reload; the locked edit pencil gets keyboard focus, shows its toolbar
  and tooltip, does not open the editor, and hides while a file uploads;
  Goal met after one automatic follow-up (In progress, Continuing 1/8,
  then "Auto-continued once") survives a reload and hides after the next
  visible message; a goal met on the first run of a new chat replaces the
  set bar and frees the pencil, and a new /goal replaces Goal met while
  its own run goes on; a /goal set or clear refused with 409 keeps the
  draft for the retry and shows its own busy toast.
- The mock POST /history head carries only the contract's
  history_head_keys, with goal only while active and goal_outcome only
  when achieved; thread state and run values omit an unset goal instead
  of sending goal: null. PUT and DELETE /goal drop the met record,
  setGoalWritesBusy returns the Gateway's 409, and a repeat create of a
  requested thread id returns the thread as is, like the Gateway.
- The raw-identifier guard also catches checker blockers and the met
  record's field names.
- frontend/src/AGENTS.md: the GoalStatus sentence now names goal,
  goal_outcome and useActiveGoal, reworded in place.

* test(frontend): keep the draft through a failed upload and a busy /goal

#6412 fires onSent only once a send is dispatched, so a failed upload
keeps the text, files and stored draft. A /goal the Gateway refuses with
409 never reaches sendMessage, and the composer rejects it so PromptInput
keeps the text. Drive both through the real sendMessage in one composer:
a failed upload keeps "summarize it" stored, a busy /goal keeps its text
and stored draft with no run, and the retry saves the goal and starts
one run with the objective, not the "/goal" text. After that run the
composer is empty and no stored draft comes back.

* fix(frontend): drop the stop condition's closing period before the pause clause

A stored stop condition often ends with a period, so the schedule card
read "…remain)., pauses itself" (and "…。,满足后自动暂停" in Chinese).
Trim the condition's closing sentence punctuation before the clause that
follows it. The event line, where the condition ends the sentence, is
unchanged.

* fix(frontend): point "evidence missing" at the agent, not a user reply

"Evidence missing" asked the user to reply with what's left, which is
the push a goal is meant to save. It now asks the agent to show the
result or explain what's missing, and offers /goal clear only for a goal that
no longer applies: clearing drops the met record, so work that is right
would never show "Goal met".

zh-CN follows. Copy only: no key, state or mapping changes, and the
scheduled page's reason labels are untouched.

tests/unit/components/workspace/goal-status-next-steps.test.ts checks
the row in en-US and zh-CN, and that no stop row offers /goal clear
without that condition. goal-status.dom.test.tsx and
goal-stream.dom.test.tsx now expect the new copy.

* fix(frontend): say "Couldn't check the goal" once in the goal bar

After a goal check that could not run, the bar's second line borrowed
the scheduled page's whole reason, so it repeated the chip: "Couldn't
check the goal · Couldn't check the goal; the run itself may be fine.
It's checked again after your next message." Chinese had the same
repeat.

The line now gives only why the check failed ("The run itself may be
fine", "No final reply was saved" or "The chat changed during the
check"), then when it is checked again. The screen-reader announcement
follows. The chip and the scheduled page's reason labels are unchanged.

tests/unit/components/workspace/goal-status-unchecked.test.ts checks
every check-failure code in en-US and zh-CN: the label appears once,
and the line still says why the check failed.

* feat(goal): redact the evaluator input when pii_redaction is enabled

The goal evaluator calls its model directly, outside
PiiRedactionMiddleware, so it sent raw user lines, tool arguments, tool
results and Human Input Card answers even with pii_redaction enabled.

With pii_redaction enabled:
- the messages in the evidence window are redacted whole before the
  evidence caps can cut an identifier in two, as TitleMiddleware does.
  This reuses redact_queued_messages, which already covers content,
  call arguments and card answers. Earlier messages are left alone: the
  evaluator never sees them, and the pass is synchronous work on the
  event loop (0.84 s for an 800-message, 4 MB thread, 0.06 s for its
  window);
- the assembled input, the goal objective included, is redacted again.

A redaction error fails the check with evaluator_failed; raw text is
never sent instead. When redaction is off, the input is byte-identical.
Thread state keeps the raw text. README.md, config.example.yaml and the
middleware docstring list the evaluator with the other redacted inputs.

tests/test_goal_pii.py puts an address where the evidence caps cut a
tool argument, a tool result and a card answer, and checks the
objective, the unchanged input when redaction is off, that only the
evidence window reaches the redactor and gives the same evidence as
redacting the whole thread, that a redaction error stands down with
evaluator_failed before the model is called, and the worker path.

* fix(goal): redact the hidden continuation when pii_redaction is enabled

The hidden /goal continuation repeats the goal objective and the
evaluator's reason and evidence summary. PiiRedactionMiddleware skips
it, since it is a framework message and not a user message, so with
pii_redaction enabled each continuation turn sent them raw to the
agent's model, and the message stayed in the thread for later model
calls. The durable-context block and the evaluator input already redact
the objective.

With pii_redaction enabled, make_goal_continuation_message now redacts
the objective, the reason and the evidence summary with redact_text
before the message is built. The thread keeps the redacted message,
which the UI hides; the goal state keeps the raw objective.

The worker builds the message before it counts the continuation. A
redaction error fails the check the way an evaluator redaction error
does: run_failed with stand_down_reason evaluator_failed, the goal stays
active, nothing is sent and the continuation budget is not spent. Only
the exception type is stored. With redaction off, the message is
byte-identical.

README.md, config.example.yaml and the middleware docstring list the
continuation with the other redacted inputs.

tests/test_goal_continuation_pii.py checks the redacted objective,
reason and evidence summary against the tokens redact_text gives, the
unchanged message with redaction off or no config, the worker path
under the run config, and a redaction error.

* docs(changelog): link the goal outcome entries to #6556

Fill the four ([#PR]) placeholders in CHANGELOG.md and CHANGELOG_zh.md with this PR's number and add the matching link references.

* fix(frontend): keep an active goal stored without timestamps visible

POST /threads/{id}/state and run input can store an active goal without
created_at or updated_at. The backend still evaluates it and rejects an
edit with 409 (is_active_goal checks only status "active"), but
isGoalState required both timestamps, so history and live values
dropped it: both chat pages hid the goal bar and unlocked the last-turn
pencil, and only a /goal status read, which used a looser predicate,
brought them back.

isGoalState now accepts any record with a string objective and status
"active", and normalizeGoalStatusRead uses it, so thread values, live
updates and status reads share one rule. Malformed or inactive values
are still dropped.

Tests cover a sparse active goal from the history head and from a live
update before the post-run refetch (bar shown, hasGoal true), and a
malformed goal that stays hidden.

* test(goal): check the outcome write metadata the same way on every saver

PostgresSaver stores checkpoint metadata through
get_serializable_checkpoint_metadata, which drops "writes", so the
[postgres] case of test_goal_outcome_round_trips_through_the_sync_fallback
failed with KeyError: 'writes' when CI ran it against a live database.

The test now records the metadata write_thread_goal passes to put and
asserts the clear's and the next goal's "writes" there, for every saver.
The stored channel values are checked as before, and on sqlite and
cached-sqlite the stored "writes" must still equal what was passed.

* docs(agents): keep goal_outcome out of the agents guidance chain

The middlewares AGENTS.md chain is already over the 98,304-byte AG002
hard limit on main, and the PR-mode check fails any chain a PR touches,
so adding goal_outcome to the ThreadState field list in
deerflow/agents/AGENTS.md failed the agent-guidance job. goal_outcome
stays documented in runtime/AGENTS.md and backend/app/gateway/AGENTS.md,
which are outside that chain.

* refactor(goal): read the evaluator's evidence and redaction from one window

format_visible_conversation recomputed the window start that the redaction
pass takes from _evidence_window. Both now use _evidence_window, so a later
change to the cap or to what counts as visible moves both.

The window test now fills the window with tool-only replies and tool results.
Before, it caught a cap change in one copy but not a change to what counts
as visible.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 00:20:45 +08:00

2775 lines
87 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;
/** Emitted as `scheduled_tasks`; each flag defaults to true. */
scheduledTasks?: {
available?: boolean;
running?: boolean;
toolEnabled?: 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;
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",
] 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,
},
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
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 },
knowledge_base: {
scope_selection_enabled:
featureFlags.knowledgeScopeSelectionEnabled,
},
scheduled_tasks: {
available: featureFlags.scheduledTasks.available,
running: featureFlags.scheduledTasks.running,
tool_enabled: featureFlags.scheduledTasks.toolEnabled,
min_interval_seconds: 60,
},
thread_activity: {
available: featureFlags.threadActivity.available,
},
}),
});
}
return route.fallback();
});
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,
});
}