deer-flow/frontend/tests/e2e/chat.spec.ts
DanielWalnut 25ea6970a6
feat(runtime): implement goal continuations (#3858)
* implement goal continuations

* fix(goal): address review findings for goal continuations

- goal: key the no-progress breaker on a signature of the latest visible
  assistant evidence instead of the evaluator's volatile free-text, so it
  actually fires on stalled turns; thread the signature through every
  worker persist / no-progress call site
- goal: align _stand_down_reason default caps with should_continue_goal
  (8 / 2) so the two gate functions agree on goals missing the fields
- runtime: offload the synchronous checkpointer fallback via
  asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop
- frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types)
- frontend: extract pure composer helpers into input-box-helpers.ts with
  unit tests (parseGoalCommand, readGoalResponseError, skill suggestions)
- tests: cover the evidence-based no-progress and default-cap behavior
- docs: align backend/AGENTS.md goal paragraph with actual behavior
- e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(frontend): hide goal continuation counter until the agent continues

The goal status bar rendered a raw "0/8" before any auto-continuation, which
read as a mysterious score. Now the counter is hidden until
continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining
the auto-continuation cap.

- Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests
- Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(goal): address review findings for goal continuations

Frontend correctness
- Fix the optimistic /goal result permanently shadowing server goal state:
  the streamed continuation counter never surfaced for a goal set in-session.
  Extract a shared useActiveGoal hook (used by both chat pages) that reconciles
  the optimistic copy with server state via a goalReconciliationKey, de-duping
  the copy-pasted goal block across the two pages.
- Stop /goal status|clear failures from escaping handleSubmit as unhandled
  rejections (handleGoalCommand now returns success; the run only starts when a
  goal was actually saved).
- Use a function replacer for the goal-status toast so an objective containing
  $&/$1 isn't treated as a replacement pattern.

Backend cleanliness / correctness
- De-duplicate four byte-identical helpers (_call_checkpointer_method,
  _message_type, _additional_kwargs, _is_visible_message) by importing them
  from runtime.goal instead of re-defining them in the run worker.
- Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has
  no tasks field) and document that pending_writes is the durability signal.
- Decompose the 176-line _prepare_goal_continuation_input: extract
  _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged
  guard and stand-down persistence aren't open-coded three times. Document the
  last-writer-wins write-window limitation as a follow-up.
- Add a shared parse_goal_command helper and use it from the TUI and IM-channel
  /goal handlers (one place for the status/clear/set semantics).

Tests
- Restore the 11 command-registry tests dropped by the previous goal change
  (filter_commands ranking/description, build_registry builtins/skills, resolve
  cases) alongside the new goal tests.
- Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal
  handler, parse_goal_command, and goalReconciliationKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix goal review feedback

* fix goal continuation checkpoint races

* prioritize goal commands while streaming

Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run.

Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check

* preserve goal status during clarification

Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint.

Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* style: format active goal hook

Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate.

Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* fix goal review followups

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:49:33 +08:00

640 lines
20 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
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("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("suggests matching skills after a leading slash", 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 expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(textarea).toHaveValue("/data-analysis ");
});
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 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(textarea).toHaveValue("/frontend-design ");
});
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("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);
});
});