import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { createServer, type ServerResponse } from "node:http"; import { responseFixtureWriter } from "./support/responses-fixture"; import { expectThemeColor } from "./support/theme-colors"; async function prepare(page: Page) { await page.emulateMedia({ reducedMotion: "reduce" }); await mockWorkbenchContext(page); await mockAgentApi(page); const counts = { sessions: 0, suggestions: 0, projects: 0 }; page.on("request", (request) => { if (request.method() === "OPTIONS") return; const path = new URL(request.url()).pathname; if (path.endsWith("/projects")) counts.projects++; if (path.endsWith("/suggestions")) counts.suggestions++; }); const sessions = Array.from({ length: 65 }, (_, index) => ({ id: `history-${index}`, session_id: `history-${index}`, title: index === 64 ? "城南供水压力与流量证据核查" : `供水监测复核 ${index + 1}`, updated_at: Date.now() - index * 60_000, created_at: Date.now() - index * 60_000, purpose: "ordinary_chat", run_status: "completed", })); await page.route("**/api/v1/sessions?**", (route) => { if (route.request().method() === "OPTIONS") return route.fallback(); counts.sessions++; const query = new URL(route.request().url()).searchParams; const filtered = sessions.filter((item) => item.title.includes(query.get("q") ?? "")); const start = Number(query.get("cursor") ?? 0); return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { sessions: filtered.slice(start, start + 30), has_more: start + 30 < filtered.length, next_cursor: start + 30 < filtered.length ? String(start + 30) : null, } }); }); const messages = Array.from({ length: 90 }, (_, index) => ({ id: `message-${index}`, role: index % 2 ? "assistant" : "user", parts: [{ type: "text", text: `第 ${index + 1} 条:供水监测证据核对。${"压力、流量和监测时窗均需复核。".repeat(5)}` }] })); await page.route(/\/api\/v1\/session\/history-[^?]+(?:\?.*)?$/, (route) => { if (route.request().method() === "OPTIONS") return route.fallback(); const url = new URL(route.request().url()); if (url.pathname.endsWith("/messages")) { const end = Number(url.searchParams.get("before")); const start = Math.max(0, end - 40); return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { messages: messages.slice(start, end), message_page: { has_more: start > 0, before_cursor: start > 0 ? String(start) : null }, } }); } const id = url.pathname.split("/").at(-1); const session = sessions.find((item) => item.id === id); if (!session) return route.fallback(); return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { ...session, messages: messages.slice(50), message_page: { has_more: true, before_cursor: "50" }, } }); }); return counts; } test("history searches all titles, loads pages and reuses lists and recommendations", async ({ page }, testInfo) => { // Includes ten full focus cycles over 60 retained rows, plus pagination and search. test.setTimeout(60_000); // Keep the activity snapshot stable while testing cache hits; real status changes have unit coverage. await page.clock.setFixedTime(new Date()); const counts = await prepare(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); const trigger = page.getByRole("button", { name: "打开 Agent 历史记录" }); await expect(page.getByRole("group", { name: "推荐问题" })).toHaveAttribute("aria-busy", "false"); await expect(page.getByRole("button", { name: "选择 Agent 模型" }).locator("svg.lucide-zap")).toBeVisible(); const baseline = { ...counts }; await trigger.click(); const dialog = page.getByRole("dialog", { name: "历史对话" }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("searchbox")).toBeFocused(); await expectThemeColor(dialog, "background-color", "--surface-reading"); await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)"); await expect(dialog.locator("[data-agent-history-header]")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(dialog.getByRole("region", { name: "Agent 历史记录" })).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(page.locator(".agent-history-overlay")).toHaveCSS("backdrop-filter", "none"); await expect(dialog.getByRole("button", { name: "关闭历史对话" }).locator("svg.lucide-x")).toHaveCSS("width", "16px"); await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(30); await dialog.getByRole("button", { name: "加载更多", exact: true }).click(); await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60); await dialog.getByRole("searchbox").fill("城南"); await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(1); await expect(dialog.getByText("城南供水压力与流量证据核查", { exact: true })).toBeVisible(); await dialog.getByRole("searchbox").fill(""); await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60); await page.keyboard.press("Escape"); await expect(trigger).toBeFocused(); const loadedCounts = { ...counts }; const cycleMs: number[] = []; for (let i = 0; i < 10; i++) { const started = Date.now(); await trigger.click(); await expect(dialog).toBeVisible(); await expect(dialog.locator(".agent-history-session-trigger")).toHaveCount(60); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); await expect(trigger).toBeFocused(); cycleMs.push(Date.now() - started); } await testInfo.attach("history-cache-metrics", { contentType: "application/json", body: JSON.stringify({ before: loadedCounts, after: counts, cycleMs }) }); expect(counts).toEqual(loadedCounts); expect(counts.suggestions).toBe(baseline.suggestions); expect(counts.projects).toBe(baseline.projects); await trigger.click(); await dialog.screenshot({ path: "test-results/agent-history/desktop.png" }); }); for (const width of [320, 375]) test(`history is full screen and preserves the mobile sheet at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 812 }); await prepare(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "打开 Agent 面板" }).click(); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); const dialog = page.getByRole("dialog", { name: "历史对话" }); await expect(dialog).toBeVisible(); await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)"); const box = (await dialog.boundingBox())!; expect(box.x).toBe(0); expect(box.y).toBe(0); expect(box.width).toBe(width); expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); await dialog.screenshot({ path: `test-results/agent-history/mobile-${width}.png` }); await page.evaluate(() => { Object.defineProperty(window.visualViewport!, "height", { configurable: true, value: 480 }); window.visualViewport!.dispatchEvent(new Event("resize")); }); await expect.poll(async () => (await dialog.boundingBox())!.height).toBe(480); await expect(dialog.getByRole("button", { name: "关闭历史对话" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); await expect(page.getByRole("region", { name: "Agent 工作台抽屉" })).toBeVisible(); }); test("older messages prepend without moving the visible reading anchor", async ({ page }) => { await prepare(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).locator(".agent-history-session-trigger").first().click(); await expect(page.getByRole("dialog", { name: "历史对话" })).toBeHidden(); await expect(page.locator("[data-agent-message-id]")).toHaveCount(40); const scroll = page.locator(".agent-conversation-scroll"); await scroll.evaluate((element) => { element.scrollTop = 1; element.dispatchEvent(new Event("scroll")); }); await expect(page.locator("[data-agent-message-id]")).toHaveCount(80); expect(await scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(100); await expect(page.getByRole("button", { name: "滚动到最新消息" })).toBeVisible(); const position = await scroll.evaluate((element) => element.scrollTop); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 2\s/ }).click(); await expect(page.locator("[data-agent-message-id]")).toHaveCount(40); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click(); await expect(page.locator("[data-agent-message-id]")).toHaveCount(80); await expect.poll(async () => Math.abs(await scroll.evaluate((element) => element.scrollTop) - position)).toBeLessThan(2); }); test("switching conversations keeps the original generation and each draft isolated", async ({ page }) => { await prepare(page); let output: ServerResponse | undefined; let aborts = 0; let readsOfRunningSession = 0; const write = responseFixtureWriter("background-a"); const server = createServer((_request, response) => { output = response; response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }); response.write(write({ type: "start", messageId: "background-answer" })); response.write(write({ type: "data-progress", data: { session_id: "background-a", title: "正在复核后台压力", status: "running" } })); response.write(write({ type: "data-session_title", data: { session_id: "background-a", title: "后台压力复核 A" } })); response.write(write({ type: "text-start", id: "background-text" })); response.write(write({ type: "text-delta", id: "background-text", delta: "已开始复核," })); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("missing fixture server address"); try { await page.route("**/api/v1/responses", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.continue({ url: `http://127.0.0.1:${address.port}/responses` })); page.on("request", (request) => { if (request.method() === "OPTIONS") return; const path = new URL(request.url()).pathname; if (path.endsWith("/abort")) aborts++; if (path.endsWith("/session/background-a")) readsOfRunningSession++; }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }); await prompt.fill("核对后台压力"); await page.getByRole("button", { name: "发送 Agent 指令", exact: true }).click(); await expect(page.getByText("已开始复核,", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click(); await prompt.fill("保留在另一会话的草稿"); await expect(page.getByRole("button", { name: /会话正在后台处理/ })).toBeVisible(); output!.write(write({ type: "text-delta", id: "background-text", delta: "后台证据核对完成。" })); output!.write(write({ type: "text-end", id: "background-text" })); output!.write(write({ type: "finish" })); output!.end(); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /后台压力复核 A\s/ }).click(); await expect(page.getByText("已开始复核,后台证据核对完成。", { exact: true })).toBeVisible(); await expect(prompt).toHaveValue(""); expect(aborts).toBe(0); expect(readsOfRunningSession).toBe(0); await page.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await page.getByRole("dialog", { name: "历史对话" }).getByRole("button", { name: /供水监测复核 1\s/ }).click(); await expect(prompt).toHaveValue("保留在另一会话的草稿"); } finally { output?.end(); server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } });