mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* 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
1470 lines
47 KiB
TypeScript
1470 lines
47 KiB
TypeScript
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);
|
||
});
|
||
});
|