deer-flow/frontend/tests/e2e/workspace-changes.spec.ts
Aari 43e5ef865d
fix(frontend): render one workspace-change card per run (#4559)
* fix(frontend): render one workspace-change card per run

The workspace-change card is resolved from (threadId, runId) alone, so
every AI message in a run fetches the identical summary. It was rendered
per AI message.

getMessageGroups() opens a separate terminal assistant group for every AI
message that has content and no tool calls, so a run ends in more than one
bubble whenever the model emits answer text mid-run that never gains a
tool call. Each bubble then painted a byte-identical "Edited N files"
card.

Fold the card onto a single position per run, matching how run duration
already anchors its own run-scoped display after the run's last visible
group. Anchoring keys on group index rather than message id because a
terminal assistant group holds exactly one message whose id may be absent.

Fixes #4555

* docs(frontend): explain the workspace-change anchor's group-type restriction

The helper narrows anchor candidates to terminal assistant groups while
getRunDurationDisplaysByGroupIndex accepts a run's last group of any type.
That asymmetry is load-bearing: run duration is emitted by MessageList
around every group, but the workspace-change card comes from
MessageListItem, which MessageList invokes only for human/assistant
groups. Anchoring a run that ends in an assistant:processing group would
pick a position that never renders and silently drop the card.

Record the rule in both places that could invite a future "unification":
the helper's docstring, and the frontend AGENTS.md paragraph that tells
maintainers where run-scoped displays belong.
2026-07-29 14:01:28 +08:00

222 lines
7.3 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const THREAD_ID = "00000000-0000-0000-0000-000000000321";
const RUN_ID = "run-workspace-changes";
test.describe("Workspace changes", () => {
test("shows changed files badge and opens the diff panel", async ({
page,
}) => {
const includeDiffValues: string[] = [];
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Workspace changes",
updated_at: "2026-07-04T10:00:00Z",
messages: [
{
type: "human",
id: "msg-human-workspace-changes",
content: [{ type: "text", text: "Create a report" }],
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-workspace-changes",
content: "I updated the workspace report.",
run_id: RUN_ID,
},
],
},
],
});
await page.route(
`**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`,
async (route) => {
const url = new URL(route.request().url());
const includeFiles = url.searchParams.get("include_files") !== "false";
includeDiffValues.push(url.searchParams.get("include_diff") ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
available: true,
version: 1,
summary: {
created: 1,
modified: 1,
deleted: 0,
symlink_created: 0,
additions: 8,
deletions: 2,
truncated: false,
},
files: includeFiles
? [
{
path: "/mnt/user-data/outputs/report.md",
root: "outputs",
status: "modified",
binary: false,
sensitive: false,
size_before: 12,
size_after: 20,
sha256_before: "before",
sha256_after: "after",
diff: "--- a/mnt/user-data/outputs/report.md\n+++ b/mnt/user-data/outputs/report.md\n@@ -1,2 +1,2 @@\n-Draft\n+Ready",
diff_truncated: false,
diff_unavailable_reason: null,
additions: 1,
deletions: 1,
},
{
path: "/mnt/user-data/workspace/notes.txt",
root: "workspace",
status: "created",
binary: false,
sensitive: false,
size_before: null,
size_after: 8,
sha256_before: null,
sha256_after: "created",
diff: "--- a/mnt/user-data/workspace/notes.txt\n+++ b/mnt/user-data/workspace/notes.txt\n@@ -0,0 +1 @@\n+Notes",
diff_truncated: false,
diff_unavailable_reason: null,
additions: 1,
deletions: 0,
},
]
: [],
limits: {},
}),
});
},
);
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.getByText("Edited 2 files")).toBeVisible({
timeout: 15_000,
});
// The human prompt carries the same run_id, but the badge must only render
// under the assistant turn — never under the user's message.
await expect(page.getByText("Edited 2 files")).toHaveCount(1);
await expect(page.getByText("outputs/report.md")).toBeVisible();
await expect(page.getByText("notes.txt")).toBeVisible();
expect(includeDiffValues).toContain("false");
await page.getByRole("button", { name: "View changes" }).click();
await expect(
page.getByRole("heading", { name: /workspace changes/i }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: /\/mnt\/user-data\/outputs\/report\.md/i,
}),
).toBeVisible();
expect(includeDiffValues).toContain("true");
await expect(page.getByText("+Ready")).toBeVisible();
await expect(page.getByText("-Draft")).toBeVisible();
});
test("renders one badge for a run that ends in two assistant bubbles", async ({
page,
}) => {
// Answer text the model emitted mid-run that never gained a tool call
// settles into its own terminal assistant bubble, so this run owns two.
// The card is resolved from (threadId, runId) alone, so rendering it per
// message painted the identical summary twice (#4555).
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Workspace changes",
updated_at: "2026-07-04T10:00:00Z",
messages: [
{
type: "human",
id: "msg-human-multi-bubble",
content: [{ type: "text", text: "Create a report" }],
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-multi-bubble-first",
content: "Let me check the workspace first.",
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-multi-bubble-final",
content: "I updated the workspace report.",
run_id: RUN_ID,
},
],
},
],
});
await page.route(
`**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
available: true,
version: 1,
summary: {
created: 0,
modified: 1,
deleted: 0,
symlink_created: 0,
additions: 8,
deletions: 2,
truncated: false,
},
files: [
{
path: "/mnt/user-data/outputs/report.md",
root: "outputs",
status: "modified",
binary: false,
sensitive: false,
size_before: 12,
size_after: 20,
sha256_before: "before",
sha256_after: "after",
diff: null,
diff_truncated: false,
diff_unavailable_reason: null,
additions: 8,
deletions: 2,
},
],
limits: {},
}),
});
},
);
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.getByText("Edited 1 file")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Edited 1 file")).toHaveCount(1);
// The surviving card belongs to the run's last bubble, matching how run
// duration anchors its own run-scoped display.
const assistantTurns = page.locator("[data-assistant-turn]");
await expect(assistantTurns).toHaveCount(2);
await expect(assistantTurns.nth(0).getByText("Edited 1 file")).toHaveCount(
0,
);
await expect(assistantTurns.nth(1).getByText("Edited 1 file")).toHaveCount(
1,
);
});
});