import { expect, test } from "@playwright/test"; import { handleRunStream, mockLangGraphAPI, MOCK_RUN_ID, MOCK_THREAD_ID, } from "./utils/mock-api"; const MOCK_AGENTS = [ { name: "test-agent", description: "A test agent for E2E tests", system_prompt: "You are a test agent.", }, { name: "second-agent", description: "Another test agent for E2E tests", system_prompt: "You are another test agent.", }, ]; test.describe("Agent chat", () => { test("display name length counts emoji as code points", async ({ page }) => { mockLangGraphAPI(page, { agents: [MOCK_AGENTS[0]!] }); let savedName: string | undefined; await page.route("**/api/agents/test-agent", async (route) => { if (route.request().method() !== "PUT") return route.fallback(); const request = route.request().postDataJSON() as { display_name: string; }; savedName = request.display_name; await route.fulfill({ json: { ...MOCK_AGENTS[0], ...request } }); }); await page.goto("/workspace/agents"); await page.getByTitle("Agent settings", { exact: true }).click(); const input = page.getByLabel("Display name", { exact: true }); await input.fill("🦌".repeat(101)); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect( page.getByText("Display name must be at most 100 Unicode code points."), ).toBeVisible(); expect(savedName).toBeUndefined(); await expect(page.getByRole("dialog")).toBeVisible(); await input.fill("🦌".repeat(100)); await expect(input).toHaveValue("🦌".repeat(100)); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect.poll(() => savedName).toBe("🦌".repeat(100)); await expect(page.getByRole("dialog")).toBeHidden(); }); test("Unicode display names keep the stable agent route and run context", async ({ page, }, testInfo) => { let streamBody: Record | undefined; const agent = { ...MOCK_AGENTS[0]!, display_name: "" }; mockLangGraphAPI(page, { agents: [agent], runStreamHandler: async (route) => { streamBody = route.request().postDataJSON() as Record; await handleRunStream(route); }, }); await page.route("**/api/agents/test-agent", async (route) => { if (route.request().method() !== "PUT") return route.fallback(); const request = route.request().postDataJSON() as { display_name: string; }; expect(request.display_name).toBe("代码审查助手"); agent.display_name = request.display_name; await route.fulfill({ json: agent }); }); await page.goto("/workspace/agents"); await page.getByTitle("Agent settings", { exact: true }).click(); await page.getByLabel("Display name", { exact: true }).fill("代码审查助手"); await page.screenshot({ path: testInfo.outputPath("display-name-settings.png"), }); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByRole("dialog")).toBeHidden(); await expect(page.getByText("代码审查助手", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Chat", exact: true }).click(); await page.waitForURL("**/workspace/agents/test-agent/chats/**"); await expect( page.getByText("代码审查助手", { exact: true }).first(), ).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("display-name-gallery.png"), }); const textarea = page.getByPlaceholder(/how can i assist you/i); await textarea.fill("Review this code"); await textarea.press("Enter"); await expect.poll(() => streamBody).toBeDefined(); expect(streamBody).toMatchObject({ context: { agent_name: "test-agent" } }); }); test("agent gallery page loads and shows agents", async ({ page }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); await page.goto("/workspace/agents"); // The agent card should appear with the agent name await expect(page.getByText("test-agent")).toBeVisible({ timeout: 15_000, }); }); for (const { label, toolGroups } of [ { label: "empty", toolGroups: [] }, { label: "unrestricted", toolGroups: null }, ]) { test(`agent gallery shows skill badges with ${label} tool groups`, async ({ page, }) => { mockLangGraphAPI(page, { agents: [ { ...MOCK_AGENTS[0]!, tool_groups: toolGroups, skills: ["data-analysis"], }, ], }); await page.goto("/workspace/agents"); const card = page.locator('[data-slot="card"]').filter({ has: page.getByText("test-agent", { exact: true }), }); await expect(card).toBeVisible({ timeout: 15_000 }); await expect( card.getByText("data-analysis", { exact: true }), ).toBeVisible(); }); } test("agent gallery hides the badge block with no tool groups or skills", async ({ page, }) => { mockLangGraphAPI(page, { agents: [{ ...MOCK_AGENTS[0]!, tool_groups: [], skills: [] }], }); await page.goto("/workspace/agents"); const card = page.locator('[data-slot="card"]').filter({ has: page.getByText("test-agent", { exact: true }), }); await expect(card).toBeVisible({ timeout: 15_000 }); await expect(card.locator('[data-slot="card-content"]')).toHaveCount(0); }); test("agent chat page loads with input box and AI disclaimer", async ({ page, }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); await page.goto("/workspace/agents/test-agent/chats/new"); // The prompt input textarea should be visible const textarea = page.getByPlaceholder(/how can i assist you/i); await expect(textarea).toBeVisible({ timeout: 15_000 }); await expect( page.getByText("DeerFlow is AI and can make mistakes", { exact: true }), ).toBeVisible(); }); test("continues an IM-selected thread with the same agent from the sidebar", async ({ page, }) => { const threadId = "00000000-0000-0000-0000-000000000168"; let streamBody: Record | undefined; mockLangGraphAPI(page, { agents: [ { name: "researcher", description: "Research agent selected from an IM channel", }, ], threads: [ { thread_id: threadId, title: "IM research conversation", metadata: { channel_source: { type: "im_channel", provider: "telegram" }, channel_agent_name: "researcher", agent_name: "researcher", }, }, ], runStreamHandler: async (route) => { streamBody = route.request().postDataJSON() as Record; await handleRunStream(route); }, }); await page.goto("/workspace/chats/new"); const threadLink = page .locator("[data-sidebar='sidebar']") .locator(`a[href='/workspace/agents/researcher/chats/${threadId}']`); await expect(threadLink).toBeVisible({ timeout: 15_000 }); await threadLink.click(); await page.waitForURL(`**/workspace/agents/researcher/chats/${threadId}`); const textarea = page.getByPlaceholder(/how can i assist you/i); await expect(textarea).toBeVisible({ timeout: 15_000 }); await textarea.fill("Continue this research"); await textarea.press("Enter"); await expect.poll(() => streamBody).toBeDefined(); expect(streamBody).toMatchObject({ context: { agent_name: "researcher", thread_id: threadId, }, }); }); test("mobile agent welcome keeps the sidebar trigger clickable", async ({ page, }) => { await page.setViewportSize({ width: 390, height: 664 }); mockLangGraphAPI(page, { agents: [ { ...MOCK_AGENTS[0]!, description: "这是一个用于验证移动端欢迎页布局的测试智能体。".repeat( 16, ), }, ], }); await page.goto("/workspace/agents/test-agent/chats/new"); await page.evaluate(() => { document.cookie = "locale=zh-CN; path=/; SameSite=Lax"; }); await page.reload(); const sidebarTrigger = page .locator("[data-sidebar='trigger']:visible") .first(); await expect(sidebarTrigger).toBeVisible({ timeout: 15_000 }); const triggerBox = await sidebarTrigger.boundingBox(); expect(triggerBox).not.toBeNull(); const triggerReceivesPointerEvents = await page.evaluate( ({ x, y }) => { const trigger = document.elementFromPoint(x, y); return trigger?.closest("[data-sidebar='trigger']") !== null; }, { x: triggerBox!.x + triggerBox!.width / 2, y: triggerBox!.y + triggerBox!.height / 2, }, ); expect(triggerReceivesPointerEvents).toBe(true); await page.mouse.click( triggerBox!.x + triggerBox!.width / 2, triggerBox!.y + triggerBox!.height / 2, ); await expect( page.locator("[data-mobile='true'][data-sidebar='sidebar']"), ).toBeVisible(); }); test("keeps new-chat drafts isolated between agents", async ({ page }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); await page.goto("/workspace/agents/test-agent/chats/new"); const firstAgentInput = page.getByPlaceholder(/how can i assist you/i); await expect(firstAgentInput).toBeVisible({ timeout: 15_000 }); await firstAgentInput.fill("Draft for the first agent"); await page.goto("/workspace/agents/second-agent/chats/new"); const secondAgentInput = page.getByPlaceholder(/how can i assist you/i); await expect(secondAgentInput).toHaveValue(""); await secondAgentInput.fill("Draft for the second agent"); await page.goto("/workspace/agents/test-agent/chats/new"); await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( "Draft for the first agent", ); }); test("agent chat page shows agent badge", async ({ page }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); await page.goto("/workspace/agents/test-agent/chats/new"); // The agent badge should display in the header (scoped to header to avoid // matching the welcome area which also shows the agent name) await expect( page.locator("header span", { hasText: "test-agent" }), ).toBeVisible({ timeout: 15_000 }); }); for (const { name, toolGroups, browserControlEnabled, expectedVisible, mock, } of [ { name: "shows Browser Live for an explicit browser tool group", toolGroups: ["browser"], browserControlEnabled: true, expectedVisible: true, }, { name: "shows Browser Live when tool groups are unrestricted", toolGroups: null, browserControlEnabled: true, expectedVisible: true, }, { name: "hides Browser Live without the browser tool group", toolGroups: ["web"], browserControlEnabled: true, expectedVisible: false, }, { name: "hides Browser Live when browser control is unavailable", toolGroups: ["browser"], browserControlEnabled: false, expectedVisible: false, }, { name: "hides Browser Live in mock custom-agent chats", toolGroups: ["browser"], browserControlEnabled: true, expectedVisible: false, mock: true, }, ]) { test(name, async ({ page }) => { const agent = { name: "browser-agent", description: "A custom agent for Browser Live tests", tool_groups: toolGroups, }; mockLangGraphAPI(page, { agents: [agent], features: { browserControlEnabled }, threads: [ { thread_id: MOCK_THREAD_ID, title: "Browser agent conversation", agent_name: agent.name, messages: [ { type: "ai", id: "msg-ai-browser-agent", content: "Ready to browse", }, ], }, ], }); const featuresLoaded = page.waitForResponse( (response) => new URL(response.url()).pathname === "/api/features" && response.status() === 200, ); await page.goto( `/workspace/agents/${agent.name}/chats/${MOCK_THREAD_ID}${mock ? "?mock=true" : ""}`, ); await featuresLoaded; if (mock) { await expect( page.locator("header span", { hasText: agent.name }), ).toBeVisible({ timeout: 15_000 }); } else { await expect(page.getByText("Ready to browse")).toBeVisible({ timeout: 15_000, }); } const browserTrigger = page.getByTestId("browser-trigger"); if (expectedVisible) { await expect(browserTrigger).toBeVisible(); await browserTrigger.click(); await expect( page.getByPlaceholder("Enter a URL and press Enter"), ).toBeVisible(); } else { await expect(browserTrigger).toHaveCount(0); } }); } test("hides Browser Live before a custom-agent thread is created", async ({ page, }) => { mockLangGraphAPI(page, { agents: [ { name: "browser-agent", description: "A custom agent for Browser Live tests", tool_groups: ["browser"], }, ], features: { browserControlEnabled: true }, }); await page.goto("/workspace/agents/browser-agent/chats/new"); await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible({ timeout: 15_000, }); await expect(page.getByTestId("browser-trigger")).toHaveCount(0); }); test("agent chat can regenerate its latest response", async ({ page }) => { const humanMessage = { type: "human", id: "msg-human-agent", content: [{ type: "text", text: "Original agent question" }], }; const aiMessage = { type: "ai", id: "msg-ai-agent", content: "Custom agent response", }; mockLangGraphAPI(page, { agents: MOCK_AGENTS, threads: [ { thread_id: MOCK_THREAD_ID, title: "Agent conversation", agent_name: "test-agent", messages: [humanMessage, aiMessage], }, ], }); let prepareMessageId: string | undefined; let streamBody: Record | undefined; await page.route( `**/api/threads/${MOCK_THREAD_ID}/runs/regenerate/prepare`, (route) => { prepareMessageId = ( route.request().postDataJSON() as { message_id?: string } ).message_id; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ input: { messages: [humanMessage] }, checkpoint: { checkpoint_id: "checkpoint-before-human", checkpoint_ns: "", checkpoint_map: null, }, metadata: { regenerate_from_message_id: aiMessage.id, regenerate_from_run_id: `run-${MOCK_THREAD_ID}`, regenerate_checkpoint_id: "checkpoint-before-human", }, target_run_id: `run-${MOCK_THREAD_ID}`, }), }); }, ); await page.route( `**/api/langgraph/threads/${MOCK_THREAD_ID}/runs/stream`, (route) => { streamBody = route.request().postDataJSON() as Record; return handleRunStream(route); }, ); await page.goto(`/workspace/agents/test-agent/chats/${MOCK_THREAD_ID}`); await expect(page.getByText(aiMessage.content)).toBeVisible({ timeout: 15_000, }); await page.evaluate((selectedText) => { const element = Array.from(document.querySelectorAll("p")).find( (candidate) => candidate.textContent?.includes(selectedText), ); const textNode = element?.firstChild; if (!element || !textNode) { throw new Error("Unable to find the custom agent response"); } const range = document.createRange(); range.selectNodeContents(textNode); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); }, aiMessage.content); await expect( page.getByRole("button", { name: "Ask in side chat" }), ).toBeVisible(); await page.keyboard.press("Escape"); const assistantTurn = page.locator("[data-assistant-turn]").last(); await assistantTurn.hover(); await page.getByRole("button", { name: "Regenerate" }).click(); await expect.poll(() => prepareMessageId).toBe(aiMessage.id); await expect.poll(() => streamBody).toBeDefined(); expect(streamBody).toMatchObject({ checkpoint: { checkpoint_id: "checkpoint-before-human", checkpoint_ns: "", checkpoint_map: null, }, metadata: { regenerate_from_message_id: aiMessage.id, regenerate_from_run_id: `run-${MOCK_THREAD_ID}`, regenerate_checkpoint_id: "checkpoint-before-human", }, context: { agent_name: "test-agent", thread_id: MOCK_THREAD_ID, }, }); }); test("agent chat can edit and rerun its latest user message", async ({ page, }) => { const humanMessage = { type: "human", id: "msg-human-agent", content: [{ type: "text", text: "Original agent question" }], }; const replacementHumanMessage = { type: "human", id: "msg-human-agent-edited", content: [{ type: "text", text: "Edited agent question" }], }; const aiMessage = { type: "ai", id: "msg-ai-agent", content: "Custom agent response", }; mockLangGraphAPI(page, { agents: MOCK_AGENTS, threads: [ { thread_id: MOCK_THREAD_ID, title: "Agent conversation", agent_name: "test-agent", messages: [humanMessage, aiMessage], }, ], }); let historyRows = [ { run_id: `run-${MOCK_THREAD_ID}`, content: humanMessage }, { run_id: `run-${MOCK_THREAD_ID}`, content: aiMessage }, ]; await page.route( `**/api/threads/${MOCK_THREAD_ID}/messages/page`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: historyRows.map((row, index) => ({ run_id: row.run_id, seq: index + 1, content: row.content, metadata: { caller: "lead_agent" }, created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`, })), has_more: false, next_before_seq: null, }), }), ); let prepareBody: | { human_message_id?: string; replacement_text?: string } | undefined; let streamBody: Record | undefined; await page.route( `**/api/threads/${MOCK_THREAD_ID}/runs/edit-regenerate/prepare`, (route) => { prepareBody = route.request().postDataJSON() as { human_message_id?: string; replacement_text?: string; }; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ input: { messages: [replacementHumanMessage] }, checkpoint: { checkpoint_id: "checkpoint-before-human", checkpoint_ns: "", checkpoint_map: null, }, metadata: { replay_kind: "edit", regenerate_from_message_id: aiMessage.id, regenerate_from_run_id: `run-${MOCK_THREAD_ID}`, regenerate_checkpoint_id: "checkpoint-before-human", edit_from_message_id: humanMessage.id, edit_message_id: replacementHumanMessage.id, edit_version_group_id: humanMessage.id, }, target_run_id: `run-${MOCK_THREAD_ID}`, replacement_human_message_id: replacementHumanMessage.id, source_message_ids: [humanMessage.id, aiMessage.id], }), }); }, ); await page.route( `**/api/langgraph/threads/${MOCK_THREAD_ID}/runs/stream`, (route) => { streamBody = route.request().postDataJSON() as Record; historyRows = [ { run_id: MOCK_RUN_ID, content: replacementHumanMessage }, { run_id: MOCK_RUN_ID, content: { type: "ai", id: "msg-ai-1", content: "Hello from DeerFlow!", }, }, ]; return handleRunStream(route); }, ); await page.goto(`/workspace/agents/test-agent/chats/${MOCK_THREAD_ID}`); await expect(page.getByText("Original agent question")).toBeVisible({ timeout: 15_000, }); const humanTurn = page.getByText("Original agent question"); await humanTurn.hover(); await page.getByRole("button", { name: "Edit and rerun" }).click(); const editor = page.locator("textarea").first(); await expect(editor).toHaveValue("Original agent question"); await editor.fill("Edited agent question"); await page.getByRole("button", { name: "Update and rerun" }).click(); await expect .poll(() => prepareBody) .toEqual({ human_message_id: humanMessage.id, replacement_text: "Edited agent question", }); await expect.poll(() => streamBody).toBeDefined(); expect(streamBody).toMatchObject({ input: { messages: [replacementHumanMessage] }, checkpoint: { checkpoint_id: "checkpoint-before-human", checkpoint_ns: "", checkpoint_map: null, }, metadata: { replay_kind: "edit", regenerate_from_message_id: aiMessage.id, regenerate_from_run_id: `run-${MOCK_THREAD_ID}`, regenerate_checkpoint_id: "checkpoint-before-human", edit_from_message_id: humanMessage.id, edit_message_id: replacementHumanMessage.id, edit_version_group_id: humanMessage.id, }, context: { agent_name: "test-agent", thread_id: MOCK_THREAD_ID, }, }); await expect(page.getByText("Edited agent question")).toBeVisible(); await expect(page.getByText("Original agent question")).not.toBeVisible(); await expect(page.getByText("Hello from DeerFlow!")).toBeVisible(); }); });