deer-flow/frontend/tests/e2e/chat.spec.ts
Zeren Wang 5951c89b5b
feat(projects): project workspaces with scoped chats and thread membership (#5265)
* feat(projects): project workspaces with scoped chats and thread membership

Backend:
- projects table model and migration; fail-closed ProjectRepository with
  ownership checks, CRUD/archive/restore/delete router, and atomic thread
  move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
  metadata; project-aware thread create/search with pagination bounds and
  membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
  at write time and dropped when invalid; serialized against project
  deletion and thread assignment
- branch creation inherits the source thread's project membership (an
  archived/deleted project degrades the branch to unassigned instead of
  failing the request)

Frontend:
- projects data layer, thread move API, and sidebar projects section with
  flat/grouped modes, archived-project threads, and stable virtual-list
  offsets
- project detail page with project-scoped new chat
  (/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
  first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
  continuation is dropped instead of saving the goal or launching the
  abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
  an open project page never keeps stale titles, recency, or pagination

* fix(chats): keep archive undo toast when the sidebar row unmounts

The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.

Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.

* fix(projects): pin project thread listing contract and exclude archived chats

GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.

The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.

Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.

* docs(migrations): record the 0019/0020 chain against the bootstrap reservation

The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.

Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.

* fix(chats): invalidate project thread lists on archive/restore

useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.

An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.

Regression test asserts the project-list prefix is invalidated on success.

* fix(projects): fetch project discovery only in grouped sidebar mode

RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.

Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.

* fix(threads): fail closed on project-scoped create in memory mode

MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).

Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.

Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.

* fix(projects): window the project page thread list

ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.

Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.

* fix(projects): restore row dividers and pin them with a render test

The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.

The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.

* fix(projects): validate forward schemas and fence membership reads
2026-09-08 17:00:26 +08:00

1470 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Page, type Route } from "@playwright/test";
import {
handleRunStream,
MOCK_THREAD_ID,
mockLangGraphAPI,
} from "./utils/mock-api";
test.describe("Project-scoped submit staleness", () => {
test.beforeEach(async ({ page }) => {
// A settled conversation to switch to mid-submit. Seeded at setup (not
// created during the test): opening a session-created thread page in the
// mock races the empty-thread redirect, while a setup-seeded thread is
// the stable pattern other specs rely on.
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Settled chat",
updated_at: "2025-06-01T12:00:00Z",
},
],
});
});
function holdThreadCreate(page: Page) {
let releaseCreate!: () => void;
let markIntercepted!: () => void;
const intercepted = new Promise<void>((resolve) => {
markIntercepted = resolve;
});
const createHeld = new Promise<void>((resolve) => {
releaseCreate = resolve;
});
void page.route("**/api/threads", async (route) => {
if (route.request().method() === "POST") {
markIntercepted();
await createHeld;
}
return route.fallback();
});
return { intercepted, releaseCreate };
}
test("switching conversations during goal preparation drops the stale continuation", async ({
page,
}) => {
// Regression: the goal PUT registers its AbortController only after the
// project pre-create resolves, so the thread-change cleanup cannot abort
// an in-flight prepare. Navigating away while preparation is pending
// must drop the continuation — no goal save, composer clear, or
// abandoned run may touch the newly opened conversation.
const textarea = page.getByPlaceholder(/how can i assist you/i);
const settledChat = page.getByRole("link", {
name: "Settled chat",
exact: true,
});
const goalPuts: string[] = [];
const runStreams: string[] = [];
page.on("request", (request) => {
const url = request.url();
if (
request.method() === "PUT" &&
/\/api\/threads\/[^/]+\/goal$/.test(url)
) {
goalPuts.push(url);
}
if (request.method() === "POST" && url.includes("/runs/stream")) {
runStreams.push(url);
}
});
const { intercepted, releaseCreate } = holdThreadCreate(page);
await page.goto("/workspace/chats/new?project=proj-1");
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
// The project pre-create must actually be in flight before we navigate:
// releasing a request that was never intercepted would make the
// assertions pass without exercising the stale continuation at all.
await intercepted;
// Switch conversations while the project pre-create is held.
await settledChat.click();
await expect(page).toHaveURL(new RegExp(`/chats/${MOCK_THREAD_ID}$`));
releaseCreate();
// Let any stale continuation run to completion before asserting: a goal
// PUT that fires late must still be caught.
await page.waitForTimeout(1500);
await expect(page.getByText("finish all tests")).toBeHidden();
await expect.poll(() => goalPuts.length).toBe(0);
await expect.poll(() => runStreams.length).toBe(0);
await expect(page).toHaveURL(new RegExp(`/chats/${MOCK_THREAD_ID}$`));
});
test("dropping the project scope mid-submission resets the thread identity", async ({
page,
}) => {
// Regression: the sidebar "New chat" link leaves /new?project=… for
// plain /new without a pathname change, so the thread identity and the
// submission fences keyed on `threadId` survive the navigation. The
// abandoned submission must not start a run against the previous
// scope's pre-created thread or rewrite the URL to it.
const textarea = page.getByPlaceholder(/how can i assist you/i);
const runStreams: string[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/runs/stream")
) {
runStreams.push(request.url());
}
});
const { intercepted, releaseCreate } = holdThreadCreate(page);
await page.goto("/workspace/chats/new?project=proj-1");
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("run inside the project");
await textarea.press("Enter");
// The project pre-create must be in flight before navigating away.
await intercepted;
await page.getByRole("link", { name: "New chat", exact: true }).click();
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
releaseCreate();
// The stale submission must not start a run for the previous scope's
// identity, nor rewrite the URL to it.
await page.waitForTimeout(1500);
await expect.poll(() => runStreams.length).toBe(0);
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
});
});
function textFromMessageContent(content: unknown) {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
return content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
test.describe("Streaming message actions", () => {
test("keeps a completed answer copyable while the next turn starts", async ({
page,
}) => {
let streamCalls = 0;
let releaseSecondStream!: () => void;
const secondStreamHeld = new Promise<void>((resolve) => {
releaseSecondStream = resolve;
});
const handleCopyRegressionStream = async (route: Route) => {
streamCalls += 1;
if (streamCalls === 2) {
await secondStreamHeld;
}
return handleRunStream(route, {}, undefined, {
responseMessage: {
type: "ai",
id: `copy-regression-ai-${streamCalls}`,
content:
streamCalls === 1 ? "First completed answer" : "Second answer",
},
messageMetadata: {
langgraph_node: "agent",
langgraph_step: streamCalls,
},
});
};
mockLangGraphAPI(page, {
createdThreadMessages: [
{
type: "human",
id: "copy-regression-human-1",
content: "First question",
},
{
type: "ai",
id: "copy-regression-ai-1",
content: "First completed answer",
},
],
runStreamHandler: handleCopyRegressionStream,
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("First question");
await textarea.press("Enter");
await expect.poll(() => streamCalls).toBe(1);
await expect(page.getByText("First completed answer")).toBeVisible({
timeout: 10_000,
});
await textarea.fill("Second question");
await textarea.press("Enter");
await expect.poll(() => streamCalls).toBe(2);
const completedTurn = page
.locator('[data-assistant-turn=""]')
.filter({ hasText: "First completed answer" });
await completedTurn.hover();
await expect(
completedTurn.getByRole("button", { name: "Copy to clipboard" }),
).toBeVisible();
} finally {
releaseSecondStream();
}
});
});
test.describe("Chat workspace", () => {
test.beforeEach(async ({ page }) => {
mockLangGraphAPI(page);
});
test("new chat page loads with input box", async ({ page }) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /load more/i })).toBeHidden();
});
test("shows the localized AI disclaimer", async ({ page }) => {
await page.goto("/workspace/chats/new");
await page.evaluate(() => {
document.cookie = "locale=zh-CN; path=/; SameSite=Lax";
});
await page.reload();
await expect(
page.getByText("内容由AI生成重要信息请务必核查", { exact: true }),
).toBeVisible({ timeout: 15_000 });
});
test("can type a message in the input box", async ({ page }) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Hello, DeerFlow!");
await expect(textarea).toHaveValue("Hello, DeerFlow!");
});
test("restores a draft after reload and clears it after sending", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Keep this unfinished draft");
await page.reload();
const restoredTextarea = page.getByPlaceholder(/how can i assist you/i);
await expect(restoredTextarea).toHaveValue("Keep this unfinished draft");
await restoredTextarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await page.reload();
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
"",
);
});
test("restores a repeated draft that matches the last sent prompt", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Repeat this request");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await expect(textarea).toHaveValue("");
await textarea.fill("Repeat this request");
await expect
.poll(() =>
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
)
.toContain("Repeat this request");
await page.reload();
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
"Repeat this request",
);
});
test("restores a selected slash skill draft after reload", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await textarea.press("Enter");
await expect(page.getByText("/data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await skillInput.fill("Analyze the latest results");
await expect
.poll(() =>
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
)
.toContain("Analyze the latest results");
await page.reload();
await expect(page.getByText("/data-analysis")).toBeVisible();
await expect(
page.getByRole("textbox", {
name: /how can i assist you/i,
}),
).toHaveText("Analyze the latest results");
});
test("continues without draft persistence when sessionStorage is blocked", async ({
page,
}) => {
let submittedText: string | undefined;
await page.addInitScript(() => {
const realSessionStorage = window.sessionStorage;
Reflect.set(window, "__blockComposerDraftStorage", false);
Object.defineProperty(window, "sessionStorage", {
configurable: true,
get() {
if (Reflect.get(window, "__blockComposerDraftStorage") === true) {
throw new DOMException("Blocked", "SecurityError");
}
return realSessionStorage;
},
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
submittedText = textFromMessageContent(content);
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => {
Reflect.set(window, "__blockComposerDraftStorage", true);
});
await textarea.fill("Send while storage is blocked");
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe("Send while storage is blocked");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("does not rewrite an accepted attachment draft from a stale debounce", async ({
page,
}) => {
let releaseUpload!: () => void;
const uploadHeld = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
let submittedText: string | undefined;
await page.route("**/api/threads/*/uploads", async (route) => {
await uploadHeld;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "notes.txt",
size: 12,
path: "notes.txt",
virtual_path: "/mnt/user-data/uploads/notes.txt",
artifact_url: "/api/threads/test/uploads/notes.txt",
extension: ".txt",
},
],
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
submittedText = textFromMessageContent(content);
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Upload files").setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("fake notes"),
});
await textarea.fill("Send this immediately");
await textarea.press("Enter");
await page.waitForTimeout(500);
expect(
await page.evaluate(() =>
Object.values(window.sessionStorage).join("\n"),
),
).not.toContain("Send this immediately");
releaseUpload();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe("Send this immediately");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await page.reload();
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
"",
);
});
test("polishes draft input before sending", async ({ page }) => {
let polishRequest: { text?: string; model_name?: string } | undefined;
let submittedText: string | undefined;
let finishPolish!: () => void;
const polishCanFinish = new Promise<void>((resolve) => {
finishPolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
polishRequest = route.request().postDataJSON() as {
text?: string;
model_name?: string;
};
await polishCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("summarize report");
await page.getByTestId("polish-input-button").click();
await expect
.poll(() => polishRequest?.text, { timeout: 10_000 })
.toBe("summarize report");
expect(polishRequest?.model_name).toBeUndefined();
await expect(textarea).toBeDisabled();
await expect(page.getByText("Polishing input...")).toBeVisible();
finishPolish();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
await expect(textarea).toBeEnabled();
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Undo polish",
);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe("Please summarize the uploaded report clearly.");
});
test("undoes polished draft from the polish button", async ({ page }) => {
await page.route("**/api/input-polish", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
}),
);
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
const polishButton = page.getByTestId("polish-input-button");
await expect(polishButton).toHaveAccessibleName("Undo polish");
await polishButton.click();
await expect(textarea).toHaveValue("summarize report");
await expect(polishButton).toHaveAccessibleName("Polish input");
});
test("cancels an in-flight polish request", async ({ page }) => {
// Hold the polish response open so the request stays in flight while we
// exercise the cancel affordance.
let releasePolish!: () => void;
const polishHeld = new Promise<void>((resolve) => {
releasePolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
await polishHeld;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(page.getByText("Polishing input...")).toBeVisible();
await expect(textarea).toBeDisabled();
await page.getByTestId("cancel-polish-input-button").click();
// Cancelling aborts the request, re-enables the composer, and leaves the
// original draft untouched (no rewrite applied).
await expect(page.getByText("Polishing input...")).toBeHidden();
await expect(textarea).toBeEnabled();
await expect(textarea).toHaveValue("summarize report");
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Polish input",
);
releasePolish();
});
test("suggests matching skills after a leading slash", async ({ page }) => {
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(page.getByText("/data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await expect(skillInput).toBeVisible();
await skillInput.fill("summarize this dataset");
await skillInput.press("Enter");
await expect
.poll(() => submittedText)
.toBe("/data-analysis summarize this dataset");
});
test("reopens the skill list with a slash after a skill is selected", async ({
page,
}) => {
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
submittedText = textFromMessageContent(
body.input?.messages?.at(-1)?.content,
);
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Enter");
await expect(page.getByText("/data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await expect(skillInput).toBeVisible();
await skillInput.pressSequentially("/");
const dataAnalysis = page.getByRole("option", { name: /data-analysis/i });
const frontendDesign = page.getByRole("option", {
name: /frontend-design/i,
});
await expect(dataAnalysis).toBeVisible();
await expect(frontendDesign).toBeVisible();
// Builtin commands own the whole composer line, so they stay out of the
// list while a skill is selected even though an empty query matches them.
await expect(page.getByRole("option", { name: /goal/i })).toBeHidden();
await skillInput.pressSequentially("fro");
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
await skillInput.press("Enter");
await expect(page.getByText("/frontend-design")).toBeVisible();
await expect(page.getByText("/data-analysis")).toBeHidden();
await skillInput.pressSequentially("polish the composer");
await skillInput.press("Enter");
await expect
.poll(() => submittedText)
.toBe("/frontend-design polish the composer");
});
test("does not offer a skill whose name a slash command owns", async ({
page,
}) => {
// Registered after the shared mock, so it wins: nothing rejects these
// names when the skill is created.
await page.route("**/api/skills", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
skills: [
{
name: "data-analysis",
description: "Analyze structured data and produce charts.",
category: "public",
enabled: true,
},
{
name: "compact",
description: "A custom skill named after a builtin command.",
category: "custom",
enabled: true,
},
{
name: "status",
description: "A custom skill named after a reserved command.",
category: "custom",
enabled: true,
},
],
}),
}),
);
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/comp");
// Reserved outside chip mode: the builtin is offered, the skill is not.
await expect(
page.getByRole("option", { name: /compact/i }),
).toHaveAccessibleName(/Compact earlier context/i);
// A contract-reserved name has no builtin standing in for it, so the list
// is empty rather than showing a skill both slash parsers would refuse.
await textarea.fill("/stat");
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Enter");
await expect(page.getByText("/data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await skillInput.pressSequentially("/comp");
// Reserved in chip mode too. Selecting it would set a `/compact` chip that
// `parseCompactCommand` intercepts on submit, so context compaction would
// run instead of the skill.
await expect(page.getByRole("option", { name: /compact/i })).toBeHidden();
await skillInput.fill("/stat");
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
});
test("goal command sets a goal and starts an agent run", async ({ page }) => {
let streamCalls = 0;
await page.goto("/workspace/chats/new");
await page.route("**/runs/stream", (route) => {
streamCalls += 1;
return route.fallback();
});
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/go");
await expect(page.getByRole("option", { name: /goal/i })).toBeVisible();
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
await expect(
page.locator("span.font-medium", { hasText: "finish all tests" }),
).toBeVisible();
await expect.poll(() => streamCalls).toBe(1);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
});
test("goal command assigns the project before saving the goal", async ({
page,
}) => {
// Regression: the goal PUT endpoint materializes a missing thread row
// itself, so the project-scoped thread create must land first — an
// unassigned row would make the later idempotent createThread return it
// without assigning the requested project.
const events: string[] = [];
let createProjectId: string | null = null;
page.on("request", (request) => {
const url = request.url();
if (request.method() === "POST" && url.endsWith("/api/threads")) {
events.push("create-thread");
createProjectId =
(request.postDataJSON() as { project_id?: string } | null)
?.project_id ?? null;
}
if (
request.method() === "PUT" &&
/\/api\/threads\/[^/]+\/goal$/.test(url)
) {
events.push("save-goal");
}
});
await page.goto("/workspace/chats/new?project=proj-1");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
await expect(
page.locator("span.font-medium", { hasText: "finish all tests" }),
).toBeVisible();
expect(createProjectId).toBe("proj-1");
expect(events.slice(0, 2)).toEqual(["create-thread", "save-goal"]);
});
test("goal command keeps the welcome header clear of the goal status", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill(
"/goal finish a small repo check and report the result",
);
await textarea.press("Enter");
const goal = page.locator("span.font-medium", {
hasText: "finish a small repo check",
});
await expect(goal).toBeVisible();
await expect(page.getByText(/welcome to/i)).toBeHidden();
const overlaps = await page.evaluate(() => {
const welcome = [...document.querySelectorAll("p")].find((el) =>
el.textContent?.toLowerCase().includes("welcome to"),
);
const goal = [...document.querySelectorAll("span")].find((el) =>
el.textContent?.includes(
"finish a small repo check and report the result",
),
);
if (!welcome || !goal) {
return false;
}
const welcomeRect = welcome.getBoundingClientRect();
const goalRect = goal.getBoundingClientRect();
return !(
welcomeRect.right < goalRect.left ||
goalRect.right < welcomeRect.left ||
welcomeRect.bottom < goalRect.top ||
goalRect.bottom < welcomeRect.top
);
});
expect(overlaps).toBe(false);
});
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/");
const dataAnalysis = page.getByRole("option", {
name: /data-analysis/i,
});
const frontendDesign = page.getByRole("option", {
name: /frontend-design/i,
});
await expect(dataAnalysis).toBeVisible();
await expect(frontendDesign).toBeVisible();
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowDown");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "false");
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await expect(frontendDesign).toHaveAttribute("aria-selected", "false");
await textarea.press("ArrowDown");
await textarea.press("Enter");
await expect(page.getByText("/frontend-design")).toBeVisible();
await expect(
page.getByRole("textbox", { name: /how can i assist you/i }),
).toBeVisible();
});
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Shift+Enter");
await expect(textarea).toHaveValue("/dat\n");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("does not suggest skills for slash text away from the prompt start", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("please /dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("sending a message triggers API call and shows response", async ({
page,
}) => {
let streamCalled = false;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Hello");
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
// The AI response should appear in the chat
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("blocks suggestion template placeholders until replaced", async ({
page,
}) => {
let streamCalled = false;
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /research/i }).click();
await expect(textarea).toHaveValue(
"Conduct a deep dive research on [topic], and summarize the findings.",
);
await textarea.press("Enter");
await page.waitForTimeout(500);
expect(streamCalled).toBe(false);
await expect(textarea).toHaveValue(
"Conduct a deep dive research on [topic], and summarize the findings.",
);
await expect
.poll(
() =>
textarea.evaluate((element) => {
const input = element as HTMLTextAreaElement;
return input.value.slice(input.selectionStart, input.selectionEnd);
}),
{ timeout: 5_000 },
)
.toBe("[topic]");
await textarea.pressSequentially("AI agents");
await expect(textarea).toHaveValue(
"Conduct a deep dive research on AI agents, and summarize the findings.",
);
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(
"Conduct a deep dive research on AI agents, and summarize the findings.",
);
});
test("slash skill command is submitted as normal chat text", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze uploads/foo.csv";
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("slash skill command with attachment preserves command text and file metadata", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze report.docx";
let uploadCalled = false;
let submittedText: string | undefined;
let submittedFiles:
| Array<{ filename?: string; path?: string; status?: string }>
| undefined;
await page.route("**/api/threads/*/uploads", async (route) => {
uploadCalled = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: {
messages?: Array<{
content?: unknown;
additional_kwargs?: {
files?: Array<{
filename?: string;
path?: string;
status?: string;
}>;
};
}>;
};
};
const message = body.input?.messages?.at(-1);
const content = message?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
submittedFiles = message?.additional_kwargs?.files;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect
.poll(() => submittedFiles, { timeout: 10_000 })
.toEqual([
{
filename: "report.docx",
size: 12,
path: "/mnt/user-data/uploads/report.docx",
status: "uploaded",
},
]);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("shows gateway upload limits on the attachment entry point", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await expect(addAttachments).toBeVisible({ timeout: 15_000 });
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("50 MiB");
await expect(page.getByRole("tooltip")).toContainText("100 MiB");
});
test("shows structured upload errors as readable messages", async ({
page,
}) => {
await page.route("**/api/threads/*/uploads", (route) =>
route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
detail: [
{
type: "missing",
loc: ["body", "files"],
msg: "Field required",
input: null,
},
],
}),
}),
);
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Upload files").setInputFiles({
name: "report.txt",
mimeType: "text/plain",
buffer: Buffer.from("report"),
});
await textarea.fill("Summarize this report");
await textarea.press("Enter");
const errorToast = page
.locator("[data-sonner-toast]")
.filter({ hasText: "body.files: Field required" });
await expect(errorToast).toBeVisible();
await expect(errorToast).not.toContainText("[object Object]");
});
test("rejects an oversized attachment before upload", async ({ page }) => {
let uploadCalled = false;
await page.route("**/api/threads/*/uploads", (route) => {
if (route.request().method() === "POST") {
uploadCalled = true;
}
return route.fallback();
});
await page.route("**/api/threads/*/uploads/limits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
max_files: 10,
max_file_size: 5,
max_total_size: 20,
}),
}),
);
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("5 B");
await page.getByLabel("Upload files").setInputFiles({
name: "too-large.txt",
mimeType: "text/plain",
buffer: Buffer.from("123456"),
});
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "too-large.txt" }),
).toBeVisible();
await expect(page.locator("form").getByText("too-large.txt")).toBeHidden();
const textarea = page.locator('textarea[name="message"]');
await textarea.fill("Continue without the rejected attachment");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
expect(uploadCalled).toBe(false);
});
test("keeps valid attachments in order when the total limit is exceeded", async ({
page,
}) => {
await page.route("**/api/threads/*/uploads/limits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
max_files: 3,
max_file_size: 10,
max_total_size: 5,
}),
}),
);
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("5 B");
await page.getByLabel("Upload files").setInputFiles([
{
name: "first.txt",
mimeType: "text/plain",
buffer: Buffer.from("1234"),
},
{
name: "over-total.txt",
mimeType: "text/plain",
buffer: Buffer.from("12"),
},
{
name: "second.txt",
mimeType: "text/plain",
buffer: Buffer.from("1"),
},
]);
const promptForm = page.locator("form").filter({
has: page.locator('textarea[name="message"]'),
});
await expect(promptForm.getByText("first.txt")).toBeVisible();
await expect(promptForm.getByText("second.txt")).toBeVisible();
await expect(promptForm.getByText("over-total.txt")).toBeHidden();
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "5 B" }),
).toBeVisible();
});
test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {
let releaseUpload!: () => void;
const uploadCanFinish = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
let uploadStarted!: () => void;
const uploadStartedPromise = new Promise<void>((resolve) => {
uploadStarted = resolve;
});
await page.route("**/api/threads/*/uploads", async (route) => {
uploadStarted();
await uploadCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
const promptForm = page.locator("form").filter({ has: textarea });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await expect(promptForm.getByText("report.docx")).toBeVisible();
await textarea.fill("Summarize this document");
await textarea.press("Enter");
await uploadStartedPromise;
await expect(promptForm.getByText("report.docx")).toBeVisible();
releaseUpload();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await expect(promptForm.getByText("report.docx")).toBeHidden();
});
test("does not fetch follow-up suggestions when disabled in config", async ({
page,
}) => {
await page.route("**/api/suggestions/config", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
});
let suggestionsFetched = false;
await page.route("**/api/threads/*/suggestions", (route) => {
suggestionsFetched = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ suggestions: [] }),
});
});
let streamCalled = false;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Hello");
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await page.waitForTimeout(1000);
expect(suggestionsFetched).toBe(false);
});
});