deer-flow/frontend/tests/e2e/thread-ordering.spec.ts
Zeren Wang 05432f4b43
fix(frontend): preserve trusted message positions through content merge (#5293)
* test(threads): add red R3/R4 merge ordering regressions

* fix(threads): preserve trusted seq positions through content merge (R3/R4)

Extract the message ordering/identity logic into a pure
core/threads/message-order.ts module. Each normalized identity now tracks
latest visible content and trusted position separately: content replacement
no longer drops deerflow_seq/run_id/turn_duration (R3), and a seq-carrying
live message is placed by the ascending seq skeleton instead of the next
shared identity anchor (R4: 1,3,2,5 -> 1,2,3,5).

buildVisibleHistoryMessages converges repeated identities to the earliest
visible feed row (mirroring backend get_message_seqs), and the
summarization transient bridge plus rendered ledger share the same
position priority: trusted seq outranks anchor weaving, bridge refreshes
keep known seqs, and hidden control copies never contribute a visible
position.

* test(e2e): add long-thread ordering regression with compaction and pagination

Add tests/e2e/thread-ordering.spec.ts: a deterministic 68-row, 33-turn
fixture with two hidden compaction summaries, a paginated /messages/page
mock, and a live compaction during submit (real SSE frame shapes). Asserts
DOM group order at stage barriers, outline/scroll navigation across the
virtualized list, tool-card association, and order stability across reload.

Also close three mock gaps in mockLangGraphAPI (token-usage, mcp-tasks,
workspace-changes): unmocked they fell through to the absent gateway and
the 401 redirected thread pages to /login, breaking every thread-page spec
in a gateway-less Playwright environment.

* test(threads): address review on ordering regression coverage

- e2e: actually expand the collapsed web_search step and assert the
  intermediate result payload (realistic JSON array fixture); assert the
  new turn's DOM relative order via compareDocumentPosition instead of
  racing viewport coordinates; add a Custom Agent route regression sharing
  the same paginated fixture.
- Add a unit test for the hidden-control-only seq fallback path.
- Keep isNonEmptyString in hooks.ts (message-order.ts does not use it).
- Document the seq-first position authority contract in frontend/src/AGENTS.md.

* test(threads): type run_id fixtures via getMessageRunId accessor

* test(e2e): exercise the real collapsed-steps region for the tool payload check

The previous toolStep.click() was a no-op: as the last tool call, the
web_search step rendered unconditionally. Add a second tool call to the
turn-30 fixture so web_search falls into the collapsed moreSteps region,
assert the intermediate result payload is hidden while collapsed, then
click the "1 more step" button and assert it becomes visible.

* docs(frontend): prettier-format AGENTS.md merge contract

* fix(frontend): anchor mixed-sequence message segments

* test(auth): include project permissions in me contracts

* fix(frontend): anchor trailing steps to positioned live results

* fix(frontend): preserve prefixes before rescued sequence anchors
2026-09-09 11:32:06 +08:00

484 lines
17 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
import {
mockLangGraphAPI,
MOCK_THREAD_ID,
MOCK_RUN_ID,
} from "./utils/mock-api";
/**
* Browser-level composition regression for the message-ordering contract
* (design §5.3): a deterministic fixture with more than one feed page
* (>50 rows), many turns, and two past compactions (the hidden summary
* rows), then a live third compaction during a newly submitted turn.
*
* Assertions happen at stage barriers: the relative DOM order of human
* messages and steps, the visible message set, and a tool card's
* association — then the same server data is reloaded and compared. The
* virtual list is exercised through scroll/outline navigation instead of
* assuming every message is in the DOM at once.
*/
const PAGE_SIZE = 50;
const THREAD = {
thread_id: MOCK_THREAD_ID,
title: "Ordering conversation",
updated_at: "2026-09-08T12:00:00Z",
};
type FeedMessage = Record<string, unknown>;
function turnMessages(turn: number): FeedMessage[] {
return [
{
type: "human",
id: `h-turn-${turn}`,
content: `turn-${turn} question`,
},
{ type: "ai", id: `a-turn-${turn}`, content: `turn-${turn} answer` },
];
}
function hiddenSummary(id: string, text: string): FeedMessage {
return {
type: "ai",
id,
name: "summary",
content: text,
additional_kwargs: { hide_from_ui: true },
};
}
/** 68 rows: 33 turns plus two hidden summary rows from past compactions. */
function buildFixtureMessages(): FeedMessage[] {
const messages: FeedMessage[] = [];
for (let turn = 0; turn <= 9; turn += 1) {
messages.push(...turnMessages(turn));
}
// First compaction collapsed everything above into this hidden summary.
messages.push(hiddenSummary("summary-1", "context summary one"));
for (let turn = 10; turn <= 19; turn += 1) {
messages.push(...turnMessages(turn));
}
// Second compaction.
messages.push(hiddenSummary("summary-2", "context summary two"));
for (let turn = 20; turn <= 29; turn += 1) {
messages.push(...turnMessages(turn));
}
// A tool-using turn so the tool card association is verifiable. Two tool
// calls put the web_search step above the LAST one — into the collapsed
// "more steps" region — so the payload check below exercises the real
// expand interaction instead of the always-open trailing step.
messages.push({
type: "human",
id: "h-turn-30",
content: "turn-30 question",
});
messages.push({
type: "ai",
id: "a-turn-30",
content: "",
tool_calls: [
{
id: "call-turn-30",
name: "web_search",
args: { query: "turn-30 lookup" },
type: "tool_call",
},
{
id: "call-turn-30-b",
name: "web_fetch",
args: { url: "https://example.test/turn-30" },
type: "tool_call",
},
],
});
messages.push({
type: "tool",
id: "t-turn-30",
tool_call_id: "call-turn-30",
name: "web_search",
// web_search steps render parsed search-result items, so the payload is
// the JSON-stringified array the real tool produces.
content: JSON.stringify([
{ url: "https://example.test/turn-30", title: "tool-30 result payload" },
]),
});
messages.push({
type: "tool",
id: "t-turn-30-b",
tool_call_id: "call-turn-30-b",
name: "web_fetch",
content: "fetched page body",
});
messages.push({
type: "ai",
id: "a-turn-30-final",
content: "turn-30 answer",
});
messages.push(...turnMessages(31));
return messages;
}
type FeedRow = {
run_id: string;
seq: number;
content: FeedMessage;
metadata: { caller: string };
created_at: string;
};
function toFeedRows(messages: FeedMessage[]): FeedRow[] {
return messages.map((message, index) => ({
run_id: `run-${MOCK_THREAD_ID}`,
seq: index + 1,
content: message,
metadata: { caller: "lead_agent" },
created_at: `2026-09-08T00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}Z`,
}));
}
/** Register a paginating `/messages/page` backed by the mutable feed rows. */
async function mockPaginatedFeed(page: Page, rows: FeedRow[]) {
await page.route(/\/api\/threads\/[^/]+\/messages\/page/, (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
const url = new URL(route.request().url());
const beforeSeqParam = url.searchParams.get("before_seq");
const eligible =
beforeSeqParam === null
? rows
: rows.filter((row) => row.seq < Number(beforeSeqParam));
const pageRows = eligible.slice(-PAGE_SIZE);
const hasMore = eligible.length > pageRows.length;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: pageRows,
has_more: hasMore,
next_before_seq: hasMore ? pageRows[0]!.seq : null,
}),
});
});
}
/** Rendered message-group indices must appear in ascending DOM order. */
async function expectGroupIndicesAscending(page: Page) {
const indices = await page
.locator("[data-message-group-index]")
.evaluateAll((elements) =>
elements.map((element) =>
Number(element.getAttribute("data-message-group-index")),
),
);
expect(indices.length).toBeGreaterThan(0);
const sorted = [...indices].sort((left, right) => left - right);
expect(indices).toEqual(sorted);
}
async function jumpToChapter(page: Page, title: string | RegExp) {
await page.getByTestId("conversation-outline-trigger").click();
// The menu sits above a smoothly-scrolling virtual list; late chapters need
// an in-menu scroll, which can starve actionability checks. The item is
// resolved and attached, so click through.
await page.getByRole("menuitem", { name: title }).click({ force: true });
}
async function loadAllHistoryPages(page: Page) {
const loadMore = page.getByRole("button", { name: "Load more" });
// Each click prepends one older page; keep clicking until exhausted. The
// near-top sentinel may already have auto-loaded a page, so the button is
// not guaranteed to be there on every pass.
for (let attempt = 0; attempt < 10; attempt += 1) {
// Clicking a menu item closes the dropdown, so each pass starts from a
// closed menu: open it, read the earliest loaded chapter, then jump to it
// with the same click.
await page.getByTestId("conversation-outline-trigger").click();
const firstItem = page.getByRole("menuitem").first();
const firstTitle = (await firstItem.textContent()) ?? "";
await firstItem.click();
if (firstTitle.includes("turn-0 question")) {
return;
}
if (!(await loadMore.isVisible())) {
// The sentinel auto-load is in flight; give it a beat to land.
await page.waitForResponse(
(response) => response.url().includes("/messages/page"),
{ timeout: 5_000 },
);
continue;
}
const pageResponse = page.waitForResponse((response) =>
response.url().includes("/messages/page"),
);
await loadMore.click();
await pageResponse;
}
throw new Error("turn-0 chapter never became reachable");
}
test.describe("Thread message ordering", () => {
// Long conversations paginate and virtualize; navigation through the
// outline plus a reload needs more than the 30s default.
test.setTimeout(90_000);
test("paginated long history renders turns in feed order and survives refresh", async ({
page,
}) => {
const rows = toFeedRows(buildFixtureMessages());
mockLangGraphAPI(page, {
threads: [{ ...THREAD, messages: rows.map((row) => row.content) }],
});
await mockPaginatedFeed(page, rows);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
// Newest page loads first and the view starts at the bottom.
await expect(page.getByText("turn-31 answer")).toBeVisible({
timeout: 15_000,
});
await expectGroupIndicesAscending(page);
// Hidden compaction summaries never render.
await expect(page.getByText("context summary one")).toHaveCount(0);
await expect(page.getByText("context summary two")).toHaveCount(0);
// Walk to the head of the conversation through pagination + outline
// navigation; the virtual list only renders the visited window. The walk
// ends on the turn-0 chapter.
await loadAllHistoryPages(page);
// Scope to the message list: the outline menu carries the same text.
await expect(
page.getByTestId("main-message-list").getByText("turn-0 question"),
).toBeVisible();
await expectGroupIndicesAscending(page);
// The tool turn keeps its card association: the web_search step sits in
// the collapsed "more steps" region, so its intermediate result payload
// is hidden until the region is expanded — and must still be there after.
await jumpToChapter(page, /turn-30 question/);
const mainList = page.getByTestId("main-message-list");
await expect(mainList.getByText("turn-30 answer")).toBeVisible();
const payload = mainList.getByText("tool-30 result payload");
await expect(payload).not.toBeVisible();
await mainList.getByRole("button", { name: "1 more step" }).click();
await expect(payload).toBeVisible();
// Same server data after a refresh reconstructs the same order.
await page.reload();
await expect(page.getByText("turn-31 answer")).toBeVisible({
timeout: 15_000,
});
await expectGroupIndicesAscending(page);
await loadAllHistoryPages(page);
await expect(
page.getByTestId("main-message-list").getByText("turn-0 question"),
).toBeVisible();
});
test("a live compaction during submit keeps history order and appends the new turn", async ({
page,
}) => {
const messages = buildFixtureMessages();
const rows = toFeedRows(messages);
mockLangGraphAPI(page, {
threads: [{ ...THREAD, messages: [...messages] }],
});
await mockPaginatedFeed(page, rows);
// A run stream in the real frame format: metadata, the summarization
// update (RemoveMessage(ALL) + hidden summary + retained tail), the
// retained tail re-arriving as messages-tuple frames, then the streamed
// answer and the end of the run. The feed grows by the new turn so the
// finishing history refetch observes it.
const compactionStream = async (route: Route) => {
const submitted = (
route.request().postDataJSON() as {
input?: { messages?: FeedMessage[] };
}
)?.input?.messages;
const submittedHuman = (submitted ?? []).find(
(message) => message.type === "human",
);
// The runtime keeps the just-submitted human in the post-compaction
// checkpoint state — the SDK-side human count only advances if the
// stream echoes it, which is also what hides the optimistic copy.
const serverHuman: FeedMessage = {
type: "human",
id: "h-turn-32",
content: submittedHuman?.content ?? "final-turn question",
};
const retainedTail = [...turnMessages(31)];
const removeAll = { id: "__remove_all__", type: "remove" };
const summary3 = hiddenSummary("summary-3", "context summary three");
const answer = {
type: "ai",
id: "a-turn-32",
content: "final-turn answer",
};
const frames = [
{
event: "metadata",
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
},
// The summarization middleware update the frontend's compaction
// rescue keys off: RemoveMessage(ALL) + hidden summary + retained
// tail, with the just-submitted human still in state.
{
event: "updates",
data: {
"DeerFlowSummarizationMiddleware.before_model": {
messages: [removeAll, summary3, ...retainedTail, serverHuman],
},
},
},
// The post-compaction checkpoint wholesale, as the established
// handleRunStream mock convention delivers it: hidden summary,
// retained tail, submitted human, and the streamed answer.
{
event: "values",
data: {
messages: [summary3, ...retainedTail, serverHuman, answer],
},
},
{ event: "end", data: {} },
];
// The persisted feed grows by the new turn so the finishing history
// refetch observes it, mirroring the journal flush on the real backend.
const base = rows.length;
const newTurn: FeedMessage[] = [serverHuman, answer];
rows.push(
...newTurn.map((message, index) => ({
run_id: `run-${MOCK_THREAD_ID}`,
seq: base + index + 1,
content: message,
metadata: { caller: "lead_agent" },
created_at: "2026-09-08T01:00:00Z",
})),
);
const body = frames
.map(
(frame) =>
`event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`,
)
.join("");
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body,
});
};
await page.route("**/api/langgraph/threads/*/runs/stream", (route) =>
compactionStream(route),
);
await page.route("**/api/langgraph/runs/stream", (route) =>
compactionStream(route),
);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("turn-31 answer")).toBeVisible({
timeout: 15_000,
});
const textarea = page.getByPlaceholder(/how can i assist you/i);
await textarea.fill("final-turn question");
await textarea.press("Enter");
// The streamed answer lands below the submitted question. Note: the
// count-based optimistic clearing does not fire when compaction shrinks
// the SDK human count below its pre-submit value (a known limitation
// tracked with the turn-ownership work), so a trailing optimistic twin
// can remain; the ordering contract asserted here is about the canonical
// server copy.
await expect(page.getByText("final-turn answer")).toBeVisible({
timeout: 15_000,
});
// DOM relative order, not viewport coordinates: stick-to-bottom smooth
// scrolling makes two separate boundingBox reads race each other.
const questionBeforeAnswer = await page.evaluate(() => {
const list = document.querySelector('[data-testid="main-message-list"]');
if (!list) {
return null;
}
const leaf = (text: string) =>
[...list.querySelectorAll("div, p")].find(
(element) =>
element.children.length === 0 && element.textContent === text,
);
const question = leaf("final-turn question");
const answer = leaf("final-turn answer");
if (!question || !answer) {
return null;
}
return (
(question.compareDocumentPosition(answer) &
Node.DOCUMENT_POSITION_FOLLOWING) !==
0
);
});
expect(questionBeforeAnswer).toBe(true);
// The finishing refetch observed the extended feed; the established
// order — including the compacted head — is unchanged.
await expectGroupIndicesAscending(page);
await expect(page.getByText("turn-31 answer")).toBeVisible();
await expect(page.getByText("context summary three")).toHaveCount(0);
await loadAllHistoryPages(page);
await expect(
page.getByTestId("main-message-list").getByText("turn-0 question"),
).toBeVisible();
// Refresh against the same server data: identical order.
await page.reload();
await expect(page.getByText("final-turn answer")).toBeVisible({
timeout: 15_000,
});
await expectGroupIndicesAscending(page);
});
test("custom agent chat shares the same ordering across pagination and refresh", async ({
page,
}) => {
// The Custom Agent route renders the same ChatPage/message pipeline; this
// pins the shared link so the ordering contract cannot regress on one
// path only.
const rows = toFeedRows(buildFixtureMessages());
mockLangGraphAPI(page, {
agents: [
{
name: "ordering-agent",
description: "Agent for the ordering regression",
},
],
threads: [
{
...THREAD,
agent_name: "ordering-agent",
messages: rows.map((row) => row.content),
},
],
});
await mockPaginatedFeed(page, rows);
await page.goto(`/workspace/agents/ordering-agent/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("turn-31 answer")).toBeVisible({
timeout: 15_000,
});
await expectGroupIndicesAscending(page);
await loadAllHistoryPages(page);
await expect(
page.getByTestId("main-message-list").getByText("turn-0 question"),
).toBeVisible();
await expectGroupIndicesAscending(page);
await page.reload();
await expect(page.getByText("turn-31 answer")).toBeVisible({
timeout: 15_000,
});
await expectGroupIndicesAscending(page);
});
});