deer-flow/frontend/tests/e2e/thread-history.spec.ts
Huixin615 2654bc60da
fix(frontend): preserve message order during long runs (#4513)
* fix(frontend): preserve message order during long runs

* test(frontend): fix history pagination regression mock

* fix(frontend): validate thread history sequences
2026-07-28 13:57:17 +08:00

718 lines
23 KiB
TypeScript
Raw Permalink 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 Route } from "@playwright/test";
import {
mockLangGraphAPI,
MOCK_THREAD_ID,
MOCK_THREAD_ID_2,
} from "./utils/mock-api";
const THREADS = [
{
thread_id: MOCK_THREAD_ID,
title: "First conversation",
updated_at: "2025-06-01T12:00:00Z",
},
{
thread_id: MOCK_THREAD_ID_2,
title: "Second conversation",
updated_at: "2025-06-02T12:00:00Z",
},
];
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
const SVG_PROMPT_THREAD_ID = "00000000-0000-0000-0000-000000000777";
const SVG_PROMPT_MARKER = "LEAK-STRICT-SVG-PROMPT-SHOULD-DISAPPEAR";
const OPTIMISTIC_PROMPT_MARKER = "LEAK-OPTIMISTIC-SVG-PROMPT-SHOULD-DISAPPEAR";
test.describe("Thread history", () => {
test("sidebar shows existing threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
await page.goto("/workspace/chats/new");
// Both thread titles should appear in the sidebar
await expect(page.getByText("First conversation")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Second conversation")).toBeVisible();
});
test("clicking a thread in sidebar navigates to it", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
await page.goto("/workspace/chats/new");
// Wait for sidebar to populate
const firstThread = page.getByText("First conversation");
await expect(firstThread).toBeVisible({ timeout: 15_000 });
// Click on the first thread
await firstThread.click();
// Should navigate to that thread's URL
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
});
test("clicking blank space in a sidebar thread row navigates to it", async ({
page,
}) => {
mockLangGraphAPI(page, { threads: THREADS });
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
const firstThreadItem = sidebar
.locator("[data-sidebar='menu-item']")
.filter({ hasText: "First conversation" })
.first();
await expect(firstThreadItem).toBeVisible({ timeout: 15_000 });
const firstThreadLink = firstThreadItem.getByRole("link");
await expect(firstThreadLink).toBeVisible();
const box = await firstThreadLink.boundingBox();
expect(box).not.toBeNull();
if (!box) {
return;
}
await firstThreadLink.click({ position: { x: 4, y: box.height / 2 } });
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
});
test("existing thread loads historical messages", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
// Navigate directly to an existing thread
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
// The historical AI response should be displayed
await expect(
page.getByText("Response in thread First conversation"),
).toBeVisible({ timeout: 15_000 });
});
test("keeps rendered messages ordered when the latest history page advances", async ({
page,
}) => {
const originalPrompt = "/ppt-master Build the quarterly presentation";
const followUpPrompt = "Continue with the approved default layout";
const olderRows = Array.from({ length: 50 }, (_, index) => {
const seq = index + 1;
if (index === 0) {
return {
run_id: "run-initial",
seq,
content: {
type: "human",
id: "history-prompt",
content: [{ type: "text", text: originalPrompt }],
},
metadata: { caller: "lead_agent" },
created_at: "2025-06-03T12:00:00Z",
};
}
if (index === 1) {
return {
run_id: "run-initial",
seq,
content: {
type: "ai",
id: "history-answer",
content: "Initial design is ready",
},
metadata: { caller: "lead_agent" },
created_at: "2025-06-03T12:00:01Z",
};
}
return {
run_id: "run-initial",
seq,
content: {
type: "ai",
id: `history-step-${seq}`,
content: `Historical presentation step ${seq}`,
...(index === 49
? { additional_kwargs: { turn_duration: 704 } }
: {}),
},
metadata: { caller: "lead_agent" },
created_at: "2025-06-03T12:00:02Z",
};
});
const initialRows = Array.from({ length: 50 }, (_, index) => {
const seq = index + 51;
return {
run_id: "run-initial",
seq,
content: {
type: "ai",
id: `history-step-${seq}`,
content: `Historical presentation step ${seq}`,
},
metadata: { caller: "lead_agent" },
created_at: "2025-06-03T12:00:03Z",
};
});
const shiftedRows = Array.from({ length: 50 }, (_, index) => {
const seq = index + 101;
return {
run_id: "run-shifted",
seq,
content: {
type: "ai",
id: `shifted-step-${seq}`,
content: `New presentation step ${seq}`,
},
metadata: { caller: "lead_agent" },
created_at: "2025-06-03T12:01:00Z",
};
});
let latestPageRequestCount = 0;
let cursorPageRequestCount = 0;
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Long presentation task",
updated_at: "2025-06-03T12:00:00Z",
// This scenario exercises persisted run-event pagination. Keep the
// checkpoint empty so its generic mock messages do not interfere
// with optimistic -> server reconciliation after the follow-up.
messages: [],
},
],
});
await page.route(
new RegExp(`/api/threads/${MOCK_THREAD_ID}/messages/page(?:\\?.*)?$`),
async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
const beforeSeq = new URL(route.request().url()).searchParams.get(
"before_seq",
);
const isLatestPage = beforeSeq === null;
const rows = isLatestPage
? latestPageRequestCount === 0
? initialRows
: shiftedRows
: beforeSeq === "101"
? initialRows
: olderRows;
const hasMore = isLatestPage || beforeSeq === "101";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: rows,
has_more: hasMore,
next_before_seq: hasMore ? (rows[0]?.seq ?? null) : null,
}),
});
if (isLatestPage) {
latestPageRequestCount += 1;
} else {
cursorPageRequestCount += 1;
}
},
);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect
.poll(() => cursorPageRequestCount, { timeout: 15_000 })
.toBeGreaterThan(0);
await expect(page.getByText(originalPrompt)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
const latestPageRequestsBeforeSubmit = latestPageRequestCount;
const textarea = page.locator("textarea[name='message']");
await textarea.fill(followUpPrompt);
await textarea.press("Enter");
await expect
.poll(() => latestPageRequestCount, { timeout: 15_000 })
.toBeGreaterThan(latestPageRequestsBeforeSubmit);
await expect(page.getByText(originalPrompt)).toBeVisible();
await expect(page.getByText(followUpPrompt)).toBeVisible();
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
const originalBox = await page.getByText(originalPrompt).boundingBox();
const followUpBox = await page.getByText(followUpPrompt).boundingBox();
expect(originalBox).not.toBeNull();
expect(followUpBox).not.toBeNull();
expect(originalBox!.y).toBeLessThan(followUpBox!.y);
});
test("shows a completed run duration once after multi-step history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Multi-step duration",
updated_at: "2025-06-03T12:00:00Z",
messages: [
{
type: "human",
id: "msg-human-duration",
content: [{ type: "text", text: "Complete several steps" }],
},
{
type: "ai",
id: "msg-ai-duration-1",
content: "Intermediate result",
additional_kwargs: { turn_duration: 114 },
},
{
type: "ai",
id: "msg-ai-duration-2",
content: "Final result",
additional_kwargs: {
turn_duration: 114,
reasoning_content: "Final synthesis reasoning",
},
},
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("Final result")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId("run-duration")).toHaveCount(1);
await expect(page.getByText("Completed in 1m 54s")).toBeVisible();
await expect(
page.getByRole("button", { name: "Reasoning", exact: true }),
).toBeVisible();
await expect(page.getByText("Thought for 114 seconds")).toHaveCount(0);
});
test("input box recalls previous prompts with arrow keys", async ({
page,
}) => {
const firstPrompt = "Summarize the latest quarterly report";
const secondPrompt = "Turn the summary into an action plan";
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Prompt history conversation",
updated_at: "2025-06-03T12:00:00Z",
messages: [
{
type: "human",
id: "msg-human-prompt-history-1",
content: [{ type: "text", text: firstPrompt }],
},
{
type: "ai",
id: "msg-ai-prompt-history-1",
content: "First answer",
},
{
type: "human",
id: "msg-human-prompt-history-2",
content: [{ type: "text", text: secondPrompt }],
},
{
type: "ai",
id: "msg-ai-prompt-history-2",
content: "Second answer",
},
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("Second answer")).toBeVisible({
timeout: 15_000,
});
const textarea = page.locator("textarea[name='message']");
await expect(textarea).toBeVisible();
await textarea.focus();
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue(secondPrompt);
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue(firstPrompt);
await textarea.press("ArrowDown");
await expect(textarea).toHaveValue(secondPrompt);
await textarea.press("ArrowDown");
await expect(textarea).toHaveValue("");
await textarea.fill("draft should not be overwritten");
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue("draft should not be overwritten");
});
test("deleting an inactive chat keeps the current chat open", async ({
page,
}) => {
mockLangGraphAPI(page, { threads: THREADS });
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(
page.getByText("Response in thread First conversation"),
).toBeVisible({ timeout: 15_000 });
const sidebar = page.locator("[data-sidebar='sidebar']");
const inactiveThreadItem = sidebar
.locator("[data-sidebar='menu-item']")
.filter({
has: page.getByRole("button", { name: /more/i }),
hasText: "Second conversation",
})
.first();
await expect(inactiveThreadItem).toBeVisible();
await inactiveThreadItem.hover();
await inactiveThreadItem.getByRole("button", { name: /more/i }).click();
await page.getByRole("menuitem", { name: /delete/i }).click();
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
await expect(
page.getByText("Response in thread First conversation"),
).toBeVisible();
await expect(sidebar.getByText("Second conversation")).toHaveCount(0);
});
test("new chat does not show previous thread messages after client-side navigation", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: SVG_PROMPT_THREAD_ID,
title: "SVG artifact prompt",
updated_at: "2025-06-03T12:00:00Z",
messages: [
{
type: "human",
id: "msg-human-svg-prompt",
content: [
{
type: "text",
text: `请严格执行:\n1. 使用 write_file 创建 /mnt/user-data/outputs/shared.svg内容包含 ${SVG_PROMPT_MARKER}\n2. 最终回复只输出 Markdown 图片。`,
},
],
},
{
type: "ai",
id: "msg-ai-svg-prompt",
content: "![shared artifact](/mnt/user-data/outputs/shared.svg)",
},
],
},
],
});
await page.goto(`/workspace/chats/${SVG_PROMPT_THREAD_ID}`);
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeVisible({
timeout: 15_000,
});
await page
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
.click();
await page.waitForURL("**/workspace/chats/new");
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
});
test("new chat does not show previous optimistic user message after client-side navigation", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID_2,
title: "Destination conversation",
updated_at: "2025-06-04T12:00:00Z",
},
],
});
const metadataOnlyStream = async (route: Route) => {
const body = [
{
event: "metadata",
data: {
run_id: "00000000-0000-0000-0000-000000000778",
thread_id: MOCK_THREAD_ID,
},
},
{ event: "end", data: {} },
]
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
.join("");
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body,
});
};
await page.route("**/api/langgraph/runs/stream", metadataOnlyStream);
await page.route(
"**/api/langgraph/threads/*/runs/stream",
metadataOnlyStream,
);
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(
`请严格执行:使用 write_file 创建 shared.svg内容包含 ${OPTIMISTIC_PROMPT_MARKER}`,
);
await textarea.press("Enter");
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toBeVisible();
await page.getByText("Destination conversation").click();
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
await page
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
.click();
await page.waitForURL("**/workspace/chats/new");
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
});
test("new chat resets immediately after a history-only thread URL update", async ({
page,
}) => {
mockLangGraphAPI(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("Message that must disappear in the next new chat");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 15_000,
});
// A newly created chat changes the URL with history.replaceState so the
// active stream is not remounted. Reproduce that history-only transition:
// the canonical pathname becomes the UUID while useParams can stay "new".
await page.evaluate((threadId) => {
history.replaceState(null, "", `/workspace/chats/${threadId}`);
}, MOCK_THREAD_ID);
const newChatLink = page.locator(
"[data-sidebar='sidebar'] a[href='/workspace/chats/new']",
);
await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${MOCK_THREAD_ID}$`),
);
await expect(newChatLink).toHaveAttribute("data-active", "false");
// One click must reset the chat without a second click or unrelated UI
// interaction forcing another render.
await newChatLink.click();
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
await expect(textarea).toBeVisible();
});
test("deleting the active newly created chat returns to the new chat screen", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route(/\/api\/threads\/[^/]+$/, (route) => {
if (route.request().method() === "DELETE") {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "Local cleanup failed" }),
});
}
return route.fallback();
});
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("What should disappear after deletion?");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 15_000,
});
const sidebar = page.locator("[data-sidebar='sidebar']");
const recentThreadItem = sidebar
.locator("[data-sidebar='menu-item']")
.filter({
has: page.getByRole("button", { name: /more/i }),
hasText: "New Chat",
})
.first();
await expect(recentThreadItem).toBeVisible();
await recentThreadItem.hover();
await recentThreadItem.getByRole("button", { name: /more/i }).click();
await page.getByRole("menuitem", { name: /delete/i }).click();
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
await expect(page.getByText("Previous question")).toHaveCount(0);
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.waitForURL("**/workspace/chats/new");
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
});
test("mock thread does not load real backend run history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: DEMO_THREAD_ID,
title: "Forecasting 2026 Trends and Opportunities",
updated_at: "2025-06-01T12:00:00Z",
messages: [
{
type: "human",
id: `run-human-${DEMO_THREAD_ID}`,
content: [
{
type: "text",
text: "This run-message endpoint should not be called.",
},
],
},
],
},
],
});
const backendRunHistoryUrls: string[] = [];
await page.route(
/\/api\/langgraph\/threads\/[^/]+\/runs(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route
.request()
.url()
.includes(`/api/langgraph/threads/${DEMO_THREAD_ID}/runs`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real runs",
}),
});
}
return route.fallback();
},
);
await page.route(
/\/api\/threads\/[^/]+\/runs\/[^/]+\/messages(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route.request().url().includes(`/api/threads/${DEMO_THREAD_ID}/runs/`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real run messages",
}),
});
}
return route.fallback();
},
);
await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
await expect(
page.getByText("What might be the trends and opportunities in 2026?"),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("I've created a modern, minimalist website"),
).toBeVisible();
expect(backendRunHistoryUrls).toEqual([]);
});
test("chats list page shows all threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
await page.goto("/workspace/chats");
// Both threads should be listed in the main content area
const main = page.locator("main");
await expect(main.getByText("First conversation")).toBeVisible({
timeout: 15_000,
});
await expect(main.getByText("Second conversation")).toBeVisible();
});
test("IM channel threads show their source in thread lists", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Feishu conversation",
updated_at: "2025-06-03T12:00:00Z",
metadata: {
channel_source: {
type: "im_channel",
provider: "feishu",
chat_id: "oc_mock",
},
},
},
],
});
await page.goto("/workspace/chats/new");
const sidebarThread = page.locator(
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
);
await expect(sidebarThread).toBeVisible({ timeout: 15_000 });
await expect(sidebarThread.getByLabel("Feishu channel")).toBeVisible();
await page.goto("/workspace/chats");
const mainThread = page
.locator("main")
.locator(`a[href='/workspace/chats/${MOCK_THREAD_ID}']`);
await expect(mainThread.getByText("Feishu conversation")).toBeVisible({
timeout: 15_000,
});
await expect(mainThread.getByText("Feishu", { exact: true })).toBeVisible();
});
});