fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -0,0 +1,113 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { responseFixtureWriter } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const finalText = "已完成三根管道的压力核查。结果属于模型计算,仍需现场核验。";
async function openStream(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let publishFinal!: () => void;
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(encode(chunk));
send({ type: "start", messageId: "answer-transition" });
send({ type: "text-start", id: "process-text" });
send({ type: "text-delta", id: "process-text", delta: Array.from({ length: 8 }, (_, i) => `第${i + 1}阶段:正在核对管道压力与模拟时段,本阶段数据仅用于分析过程。`).join("\n\n") });
for (let i = 0; i < 6; i++) send({ type: "data-progress", data: { id: `step-${i}`, phase: "tool", title: `核对资料步骤 ${i + 1}`, detail: "查询监测记录并检查时段与单位。", status: i === 5 ? "running" : "completed" } });
send({ type: "text-end", id: "process-text" });
publishFinal = () => send({ type: "data-final_answer", data: { content: finalText, ui_block_ids: [] } });
finish = () => { send({ type: "finish", finishReason: "stop" }); response.end(); };
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("核对压力");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="answer-transition"]');
await expect(message.getByText("核对资料步骤 6", { exact: true })).toBeVisible();
return { panel, message, publishFinal: () => publishFinal(), finish: () => finish(), close: async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
} };
}
for (const width of [1440, 320]) {
for (const reducedMotion of ["reduce", "no-preference"] as const) {
test(`final report settles once before transport close at ${width}px with ${reducedMotion}`, async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion });
const stream = await openStream(page, width);
try {
const { message } = stream;
await message.locator("[data-agent-progress]").evaluate((node) => { node.setAttribute("data-original-progress", "true"); });
stream.publishFinal();
await expect(message.getByText(finalText, { exact: true })).toBeVisible();
await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
await expect(message.getByText("正在回复", { exact: true })).toHaveCount(0);
await expect(message.locator("details[data-agent-process]")).not.toHaveAttribute("open");
const settled = await message.boundingBox();
await message.screenshot({ path: testInfo.outputPath(`report-ready-${width}-${reducedMotion}.png`) });
stream.finish();
await expect(stream.panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
const after = await message.boundingBox();
expect(Math.abs(after!.height - settled!.height)).toBeLessThanOrEqual(1);
expect(Math.abs(after!.y - settled!.y)).toBeLessThanOrEqual(1);
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
const process = message.locator("details[data-agent-process]");
await expect(process.getByText(/第1阶段:/)).toBeVisible();
await expect(process.locator('[data-original-progress="true"]')).toBeVisible();
} finally { await stream.close(); }
});
}
}
for (const reducedMotion of ["reduce", "no-preference"] as const) test(`reading a live process still folds on completion and retains tool details with ${reducedMotion}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion });
const stream = await openStream(page, 1440);
try {
const { message, panel } = stream;
const tool = message.locator("details[data-agent-tool]").last();
await tool.locator("summary").click();
const detail = tool.getByText("查询监测记录并检查时段与单位。", { exact: true });
await expect(detail).toBeVisible();
await expect(tool).toHaveAttribute("data-disclosure-motion", "idle");
const scroll = panel.locator(".agent-conversation-scroll");
await scroll.hover();
await page.mouse.wheel(0, -300);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const reading = message.getByText(/第1阶段:/).first();
await expect(reading).toBeInViewport();
// Finish the reader's wheel movement and any preceding disclosure layout
// before measuring the separate final-report handoff.
let lastTop: number | undefined;
await expect.poll(async () => {
const top = (await reading.boundingBox())!.y;
const stable = top === lastTop;
lastTop = top;
return stable;
}).toBe(true);
stream.publishFinal();
await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
const process = message.locator("details[data-agent-process]");
await expect(process).not.toHaveAttribute("open");
await expect(detail).toBeHidden();
await expect(message.locator("[data-answer-phase]")).toHaveAttribute("data-answer-phase", "settled");
await expect(message.getByText(finalText, { exact: true })).toBeInViewport();
stream.finish();
await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
await expect(process).not.toHaveAttribute("open");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(process).toHaveAttribute("open", "");
await expect(process.locator("details[data-agent-tool]").last()).toHaveAttribute("open", "");
await expect(detail).toBeVisible();
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(process).not.toHaveAttribute("open");
} finally { await stream.close(); }
});
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import type { InteractionAnswer, InteractionRequest } from "../../src/features/agent/api/interaction-requests";
import { mockAgentApi } from "./support/mock-agent-api";
for (const width of [1440, 375, 320]) {
test(`question card aligns wrapped choices and accepts a text-only answer at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="question-reader" data-agent-theme="chat" style="max-width:700px;margin:16px auto;padding:12px"></main></body></html>');
await page.evaluate(async () => {
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { AgentInteractionRequestCard } = await import("/src/features/agent/components/agent-interaction-request-card.tsx");
const root = createRoot(document.getElementById("question-reader"));
function Fixture({ custom }: { custom: boolean }) {
const [draft, setDraft] = React.useState([] as InteractionAnswer[]);
const [submitted, setSubmitted] = React.useState(false);
const record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: custom ? "free-text" : "closed-choices", message_id: "m", turn_id: "t", call_id: "c",
questions: [{ question_id: "target", title: "确认要定位的对象", body: custom ? "可直接填写需要定位的对象和地址线索。" : "当前记录包含公司上报地址和三组模拟管段位置。不同目标会在地图上定位到不同位置,请选择要定位的对象。",
options: custom ? [] : [
{ id: "company", label: "公司上报地址:安徽省滁州市凤阳县西泉镇的公司点位" },
{ id: "pipe1", label: "模拟管段 Pipe19026(用户给出的约315米位置)" },
{ id: "pipe2", label: "模拟管段 Pipe18751(用户给出的约516米位置)" },
{ id: "pipe3", label: "模拟管段 Pipe18750(用户给出的约523米位置)" }
], mode: "single", required: true, allow_custom: custom }],
status: submitted ? "answered" : "pending", revision: submitted ? 1 : 0, draft_revision: 0, draft: [], created_at: "2026-10-28T09:19:24Z",
...(submitted ? { answers: draft.map((answer: InteractionAnswer) => ({ ...answer, selected_options: [] })) } : {}) };
return React.createElement(AgentInteractionRequestCard, { entry: { record, draft, dirty: draft.length > 0, busy: false }, checking: false,
onChange: (next: InteractionAnswer[]) => setDraft(next), onAction: (action: string) => { if (action === "reply") setSubmitted(true); } });
}
const render = (custom: boolean) => root.render(React.createElement(Fixture, { key: String(custom), custom }));
Object.assign(window, { showTextOnlyQuestion: () => render(true) });
render(false);
});
const card = page.getByRole("article", { name: "需要补充的信息" });
await expect(card.getByRole("radio")).toHaveCount(4);
await expect(card.getByRole("textbox")).toHaveCount(0);
await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeDisabled();
const choice = card.getByRole("radio").last();
const backgroundBefore = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
await choice.focus();
await page.keyboard.press("Space");
await expect(choice).toBeChecked();
const backgroundAfter = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
expect(backgroundAfter).not.toBe(backgroundBefore);
const offsets = await card.getByRole("radio").evaluateAll((inputs) => inputs.map((input) => {
const label = input.closest("label")!;
const text = label.lastElementChild!;
const radioBounds = input.getBoundingClientRect();
const textBounds = text.getBoundingClientRect();
return Math.abs(radioBounds.top + radioBounds.height / 2 - textBounds.top - parseFloat(getComputedStyle(text).lineHeight) / 2);
}));
expect(Math.max(...offsets)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await card.screenshot({ path: testInfo.outputPath(`question-choices-${width}.png`) });
await page.evaluate(() => (window as unknown as { showTextOnlyQuestion: () => void }).showTextOnlyQuestion());
await expect(card.getByRole("radio")).toHaveCount(0);
const custom = card.getByRole("textbox", { name: "确认要定位的对象:补充说明" });
await custom.fill("请定位公司上报地址,并保留现有管段标记。");
await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeEnabled();
await card.screenshot({ path: testInfo.outputPath(`question-custom-${width}.png`) });
await card.getByRole("button", { name: "提交回答", exact: true }).click();
const reply = page.getByRole("group", { name: "已提交的回答" });
await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
await expect(card).toHaveCount(0);
await expect(page.getByRole("textbox")).toHaveCount(0);
await expect(reply.getByText("请定位公司上报地址,并保留现有管段标记。", { exact: true })).toBeVisible();
});
}
@@ -0,0 +1,173 @@
import { expect, test } from "@playwright/test";
import { responseFixtureWriter } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import type { InteractionRequest } from "../../src/features/agent/api/interaction-requests";
for (const width of [1440, 320]) {
test(`host question restores draft and requires explicit submit at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page, { sessions: [{ session_id: "host-question", title: "待补充范围", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "核查范围", body: "选择本次核查范围", options: [{ id: "north", label: "北侧管网" }, { id: "all", label: "全部管网" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
const writes: string[] = [];
const inputs: Array<{ id: string; text: string; delivery: string; status: string }> = [];
await page.route("**/api/v1/responses", (route) => {
const body = route.request().postDataJSON();
expect(body.stream).toBe(false);
const input = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
inputs.push(input);
return route.fulfill({ headers, status: 202, json: input });
});
await page.route("**/api/v1/session/host-question/inputs", (route) => route.fulfill({ headers, json: { items: inputs } }));
await page.route("**/api/v1/session/host-question?**", (route) => route.fulfill({ headers, json: { session_id: "host-question", title: "待补充范围", purpose: "ordinary_chat", status: "active", is_streaming: false, messages: [{ id: "m", role: "assistant", parts: [{ type: "text", text: "请补充核查范围。" }] }] } }));
await page.route("**/api/v1/session/host-question/interactions**", async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
const action = new URL(request.url()).pathname.split("/").at(-1)!;
if (request.method() === "GET") return route.fulfill({ headers, json: action === "interactions" ? { items: [record] } : record });
writes.push(action);
const body = request.postDataJSON();
if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
if (action === "cancel") record = { ...record, status: "cancelled", revision: 1 };
return route.fulfill({ headers, json: record });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "待补充范围" }).click();
const card = panel.getByRole("article", { name: "需要补充的信息" });
await expect(card).toBeVisible();
await expect(card.getByRole("button", { name: "提交回答" })).toBeDisabled();
await expect(card.getByRole("button", { name: "跳过", exact: true })).toHaveCount(0);
await card.getByRole("radio", { name: "北侧管网" }).check();
await card.getByRole("textbox", { name: "核查范围:补充说明" }).fill("优先检查压力下降区域");
await expect.poll(() => record.draft[0]?.custom_text).toBe("优先检查压力下降区域");
expect(writes.length).toBeGreaterThan(0);
expect(writes.every((action) => action === "draft")).toBe(true);
await expect(card.getByRole("button", { name: "提交回答" })).toBeEnabled();
await card.screenshot({ path: `/tmp/host-question-${width}.png` });
const bounds = await card.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("先核对压力测点");
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect.poll(() => inputs.map((input) => input.delivery)).toEqual(["steer"]);
await expect(card.getByText("等待回答", { exact: true })).toBeVisible();
expect(writes.every((action) => action === "draft")).toBe(true);
await card.getByRole("button", { name: "取消提问" }).click();
await expect(card.getByText("已取消", { exact: true })).toBeVisible();
await expect(card.getByRole("button", { name: "提交回答" })).toHaveCount(0);
});
}
for (const responseMode of ["completed_without_event", "pending_conflict"] as const) {
test(`collaboration restores a persisted question after ${responseMode} and submits its answer`, async ({ page }, testInfo) => {
const width = responseMode === "pending_conflict" ? 320 : 1440;
await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
const sessionId = "collaboration-question";
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
let pending = false;
let listReads = 0;
let responses = 0;
let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "locate-target", message_id: "question-message", turn_id: "question-turn", call_id: "ask-call", questions: [{ question_id: "target", title: "选择定位对象", body: "选择公司上报地址或模拟管段。", options: [{ id: "company", label: "公司上报地址" }, { id: "pipes", label: "模拟管段" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: { session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", status: "active", is_streaming: false, messages: [{ id: "context-message", role: "assistant", parts: [{ type: "text", text: "调查报告已就绪。" }] },
...(record.delivery ? [{ id: record.delivery.native_message_id, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:\n选择定位对象:公司上报地址;使用报告中的公司位置" }] }] : [])] } }));
await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
const action = new URL(request.url()).pathname.split("/").at(-1)!;
if (request.method() === "GET") {
if (action === "interactions") listReads++;
return route.fulfill({ headers, json: action === "interactions" ? { items: pending ? [record] : [] } : record });
}
const body = request.postDataJSON();
if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
if (action === "reply") record = { ...record, status: "answered", revision: 1,
answers: body.answers.map((answer: { question_id: string; selected_option_ids: string[]; custom_text: string }) => ({ ...answer, selected_options: record.questions[0].options.filter((option) => answer.selected_option_ids.includes(option.id)) })),
delivery: { native_message_id: `msg_${"a".repeat(32)}`, status: "queued", revision: 0 } };
if (action === "resume") record = { ...record, delivery: { ...record.delivery!, status: "queued", revision: record.delivery!.revision + 1 } };
return route.fulfill({ headers, json: record });
});
await page.route("**/api/v1/responses", (route) => {
responses++;
const body = route.request().postDataJSON();
if (responses === 1) {
pending = true;
if (responseMode === "pending_conflict") return route.fulfill({ headers, status: 409, json: { code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" } });
} else {
expect(body.tjwater.interaction_id).toBe(record.interaction_id);
expect(record.answers?.[0]).toMatchObject({ selected_option_ids: responseMode === "pending_conflict" ? ["company"] : [], custom_text: "使用报告中的公司位置" });
if (responseMode === "pending_conflict" && responses === 2) {
record = { ...record, delivery: { ...record.delivery!, status: "blocked", revision: 1 } };
return route.fulfill({ headers, status: 503, body: "暂时无法继续,请重试。" });
}
record = { ...record, delivery: { ...record.delivery!, status: "delivered", revision: record.delivery!.revision + 1 } };
}
const write = responseFixtureWriter(sessionId);
return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: [
{ type: "start", messageId: responses === 1 ? "question-message" : "located-message" },
{ type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: responses === 1 ? "请选择要定位的对象。" : "已定位公司上报地址。" },
{ type: "text-end", id: "text" },
{ type: "finish", finishReason: "stop" }
].map(write).join("") });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
await expect.poll(() => listReads).toBeGreaterThan(0);
await expect(panel.getByRole("article", { name: "需要补充的信息" })).toHaveCount(0);
const readsBeforeSend = listReads;
const input = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await input.fill("定位到公司上报地址");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const card = panel.getByRole("article", { name: "需要补充的信息" });
await expect(card).toBeVisible();
expect(listReads).toBeGreaterThan(readsBeforeSend);
await expect(panel.getByText("离线", { exact: true })).toHaveCount(0);
await expect(panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true })).toBeDisabled();
if (responseMode === "pending_conflict") await card.getByRole("radio", { name: "公司上报地址", exact: true }).check();
await card.getByRole("textbox", { name: "选择定位对象:补充说明" }).fill("使用报告中的公司位置");
await card.screenshot({ path: testInfo.outputPath(`recovered-${responseMode}.png`) });
await card.getByRole("button", { name: "提交回答", exact: true }).click();
const reply = panel.getByRole("group", { name: "已提交的回答" });
await expect(reply).toHaveCount(1);
await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
if (responseMode === "pending_conflict") {
await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
await reply.getByRole("button", { name: "重试继续", exact: true }).click();
}
await expect(reply.getByText("已继续处理", { exact: true })).toBeVisible();
await expect(card).toHaveCount(0);
await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
expect(await reply.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await reply.screenshot({ path: testInfo.outputPath(`submitted-${responseMode}.png`) });
await reply.getByText("使用报告中的公司位置", { exact: true }).click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("使用报告中的公司位置");
await expect(panel.getByText("已定位公司上报地址。", { exact: true })).toBeVisible();
expect(responses).toBe(responseMode === "pending_conflict" ? 3 : 2);
await expect(reply).toHaveCount(1);
await input.fill("继续核查");
await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeEnabled();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
await expect(reply).toHaveCount(1);
await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
await expect(card).toHaveCount(0);
});
}
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
for (const width of [1440, 320]) {
test(`restored clarification process belongs to its continuation at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
const sessionId = "clarification-process";
const nativeReplyId = `msg_${"b".repeat(32)}`;
const continuationId = `answer_${nativeReplyId}`;
const progress = (id: string, title: string) => ({ type: "data-progress", data: {
id, title, phase: "tool", status: "completed", detail: "合成演练记录。"
} });
const messages: Array<{ id: string; role: string; parts: Array<{ type: string; [key: string]: unknown }> }> = [
{ id: "original-user", role: "user", parts: [{ type: "text", text: "分别模拟三根管道。" }] },
{ id: "clarifying-message", role: "assistant", parts: [progress("query", "核对管道与时段")] },
{ id: nativeReplyId, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:按顺序对应时段" }] },
{ id: continuationId, role: "assistant", parts: [
progress("query", "完成对应时段模拟"),
{ type: "text", text: "三根管道的模拟结果已整理,仅供演练。" }
] }
];
const interaction = {
version: "agent-interaction-request@1", interaction_id: "clarification", message_id: "clarifying-message",
turn_id: "clarification-turn", call_id: "ask", status: "answered", revision: 1, draft_revision: 0,
draft: [], created_at: "2026-10-28T10:00:00Z",
questions: [{ question_id: "mapping", title: "管道与时段对应", body: "请确认对应顺序。", mode: "single", required: true,
allow_custom: false, options: [{ id: "ordered", label: "按顺序对应时段" }] }],
answers: [{ question_id: "mapping", selected_option_ids: ["ordered"], custom_text: "", selected_options: [{ id: "ordered", label: "按顺序对应时段" }] }],
delivery: { native_message_id: nativeReplyId, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" }
};
messages[2].parts.push({ type: "data-interaction_reply", data: interaction });
let releaseInteractions!: () => void;
let interactionGate = new Promise<void>((resolve) => { releaseInteractions = resolve; });
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "补充问题后继续模拟", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*" };
let writes = 0;
let finishBeforeNextInput = false;
const queued: Array<{ id: string; text: string; delivery: string; status: string }> = [];
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
session_id: sessionId, title: "补充问题后继续模拟", purpose: "ordinary_chat", status: "active", is_streaming: false, messages
} }));
await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
if (route.request().method() !== "GET") writes++;
await interactionGate;
return route.fulfill({ headers, json: { items: [interaction] } });
});
await page.route(`**/api/v1/session/${sessionId}/inputs`, (route) => route.fulfill({ headers, json: { items: queued } }));
await page.route("**/api/v1/responses", (route) => {
writes++;
const body = route.request().postDataJSON();
if (finishBeforeNextInput) {
expect(typeof body.input === "string" ? body.input : body.input[0].content[0].text).toBe("继续检查模拟结果");
if (body.stream === false) {
interaction.delivery.status = "delivered";
return route.fulfill({ headers, status: 409, json: { code: "RESPONSE_FINISHED", message: "Finished before admission" } });
}
return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: responsesFixture([
{ type: "start", messageId: "next-response" }, { type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: "已开始新一轮核查。" }, { type: "text-end", id: "text" }, { type: "finish", finishReason: "stop" }
]) });
}
expect(body.stream).toBe(false);
expect(body.conversation).toBe(sessionId);
const receipt = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
queued.push(receipt);
return route.fulfill({ headers, status: 202, json: receipt });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
const continuation = panel.locator(`[data-agent-message-id="${continuationId}"]`);
await expect(continuation.getByText("三根管道的模拟结果已整理,仅供演练。", { exact: true })).toBeVisible();
const reply = panel.getByRole("group", { name: "已提交的回答" });
await expect(reply).toHaveCount(1);
await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
await expect(reply.getByText("按顺序对应时段", { exact: true })).toBeVisible();
releaseInteractions();
await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
await expect(panel.getByRole("button", { name: "查看处理过程", exact: true })).toHaveCount(1);
const toggle = continuation.getByRole("button", { name: "查看处理过程", exact: true });
await toggle.click();
const process = continuation.locator("details[data-agent-process]");
await expect(process.getByText("核对管道与时段", { exact: true })).toBeVisible();
await expect(process.getByText("完成对应时段模拟", { exact: true })).toBeVisible();
await expect(process.locator("[data-agent-progress]")).toHaveCount(1);
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(2);
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("[data-tool-name]")).toHaveText(["核对管道与时段", "完成对应时段模拟"]);
await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
const reportBox = (await continuation.locator("[data-answer-parts]").first().boundingBox())!;
const processBox = (await process.boundingBox())!;
expect(processBox.y).toBeGreaterThanOrEqual(reportBox.y + reportBox.height);
await panel.screenshot({ path: testInfo.outputPath(`continued-process-${width}.png`) });
// A hard reload must keep the same human card while metadata is delayed again.
interactionGate = new Promise<void>((resolve) => { releaseInteractions = resolve; });
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
await expect(reply).toHaveCount(1);
await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
await expect(toggle).toHaveCount(1);
releaseInteractions();
await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
expect(writes).toBe(0);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("先核查压力数据");
await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
await page.getByRole("menuitemradio", { name: "排队处理", exact: true }).click();
await panel.getByRole("button", { name: "将消息加入 Agent 队列", exact: true }).click();
await expect(panel.getByRole("region", { name: "待处理消息队列" }).getByText("先核查压力数据", { exact: true })).toBeVisible();
await prompt.fill("优先检查第二根管道");
await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
await page.getByRole("menuitemradio", { name: "引导当前任务", exact: true }).click();
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect.poll(() => queued.map((item) => item.delivery)).toEqual(["queue", "steer"]);
expect(writes).toBe(2);
finishBeforeNextInput = true;
await prompt.fill("继续检查模拟结果");
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect(panel.getByText("已开始新一轮核查。", { exact: true })).toBeVisible();
expect(writes).toBe(4);
});
}
+112
View File
@@ -0,0 +1,112 @@
import { expect, test, type Locator } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responseFixtureWriter } from "./support/responses-fixture";
async function readRowStyle(row: Locator) {
return row.evaluate((element) => {
const style = getComputedStyle(element);
const text = element.querySelector("span.min-w-0 > span")!;
const icon = element.querySelector('[role="img"]')!;
return {
columns: style.gridTemplateColumns.split(" ")[0],
gap: style.columnGap,
fontSize: style.fontSize,
titleWeight: getComputedStyle(text).fontWeight,
titleLineHeight: getComputedStyle(text).lineHeight,
iconWidth: icon.getBoundingClientRect().width,
iconHeight: icon.getBoundingClientRect().height
};
});
}
for (const width of [1440, 320]) {
test(`live progress matches archived rows and retains folding at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
const steps = [
{ id: "start", title: "已收到请求", status: "completed", detail: "正在核对当前项目。" },
{ id: "context", title: "Agent 正在处理请求", status: "running", detail: "Agent 正在执行推理、工具调用或结果整理。" },
{ id: "data", title: "获取业务数据", status: "running", detail: "正在核对附近阀门、关联管道与隔离范围。" },
{ id: "isolation", phase: "tool", title: "阀门隔离分析", status: "running", tool: {
name: "mcp_tjwater_analyze_valve_isolation", call_id: "isolation-call", input: { link_ids: [533793, 486121, 523616] }
} }
];
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const write = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(write(chunk));
send({ type: "start", messageId: "consistent-progress" });
for (const step of steps) send({ type: "data-progress", data: step });
finish = () => {
for (const step of steps) send({ type: "data-progress", data: { ...step, status: "completed" } });
send({ type: "data-final_answer", data: { content: "已核对附近可用于隔离的阀门。" } });
send({ type: "finish", finishReason: "stop" });
response.end();
};
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("找下附近可用的阀门,用于隔离");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="consistent-progress"]');
const progress = message.locator("[data-agent-progress]");
const recent = progress.getByRole("list", { name: "最近执行进度" });
await expect(recent.locator("li")).toHaveCount(3);
const container = message.locator("details[data-agent-live-process]");
await expect(container).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(container).toHaveCSS("border-radius", "12px");
await expect(container).toHaveCSS("border-top-width", "1px");
await expect(progress).toHaveCSS("border-top-width", "0px");
const containerStyle = await container.evaluate((element) => {
const style = getComputedStyle(element);
return [style.borderColor, style.borderRadius, style.backgroundColor];
});
const liveRow = recent.locator("li").filter({ hasText: "Agent 正在处理请求" });
const liveStyle = await readRowStyle(liveRow);
expect(liveStyle).toMatchObject({ columns: "24px", gap: "8px", fontSize: "14px", titleWeight: "500", iconWidth: 24, iconHeight: 24 });
await expect(liveRow.getByRole("img", { name: "进行中", exact: true })).toBeVisible();
await progress.getByRole("button", { name: "展开执行进度", exact: true }).click();
await expect(progress.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4);
await progress.getByRole("button", { name: "收起执行进度", exact: true }).click();
await expect(recent.locator("li")).toHaveCount(3);
const tool = progress.locator("details[data-agent-tool]");
await expect(tool).not.toHaveAttribute("open");
await tool.locator("summary").click();
await expect(tool.locator("pre")).toContainText("533793");
await message.screenshot({ path: testInfo.outputPath(`live-progress-${width}.png`) });
finish();
const archived = message.locator("details[data-agent-process]");
await expect(archived).not.toHaveAttribute("open");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(archived).toHaveAttribute("open", "");
await expect(archived).toHaveCSS("background-color", "rgb(255, 255, 255)");
expect(await archived.evaluate((element) => {
const style = getComputedStyle(element);
return [style.borderColor, style.borderRadius, style.backgroundColor];
})).toEqual(containerStyle);
const archivedRow = archived.getByRole("list", { name: "全部执行进度" }).locator("li").filter({ hasText: "Agent 正在处理请求" });
expect(await readRowStyle(archivedRow)).toEqual(liveStyle);
await expect(archivedRow.getByRole("img", { name: "已完成", exact: true })).toBeVisible();
await expect(tool).toHaveAttribute("open", "");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(progress).toBeHidden();
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(progress).toBeVisible();
await message.screenshot({ path: testInfo.outputPath(`archived-progress-${width}.png`) });
expect(await progress.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
}
+18 -5
View File
@@ -79,7 +79,7 @@ for (const width of [1440, 375, 320]) {
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await composer.fill("核对压力变化");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
const deliveryMode = page.getByRole("button", { name: "消息发送方式", exact: true });
await expect(deliveryMode).toHaveText("引导");
await deliveryMode.focus();
await page.keyboard.press("ArrowDown");
@@ -93,12 +93,21 @@ for (const width of [1440, 375, 320]) {
await expect(deliveryMode).toBeFocused();
await expect(deliveryMode).toHaveText("引导");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
const stopButton = page.getByRole("button", { name: "停止 Agent 指令" });
await expectThemeColor(stopButton, "background-color", "--status-danger-soft");
await expectThemeColor(stopButton, "color", "--status-danger-foreground");
await stopButton.hover();
await expectThemeColor(stopButton, "background-color", "--status-danger-border");
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("stop-action-hover.png"), animations: "disabled" });
const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox();
await composer.fill("补充当前任务");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0);
const sendSlot = await page.getByRole("button", { name: "引导当前 Agent 任务" }).boundingBox();
expect(sendSlot).toEqual(actionSlot);
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
const sendButton = page.getByRole("button", { name: "引导当前 Agent 任务" });
await expect(sendButton).toHaveAttribute("data-agent-action", "send");
expect(await sendButton.boundingBox()).toEqual(actionSlot);
await expect(page.getByRole("button", { name: "权限批准模式" })).toHaveText("请求批准");
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("streaming-composer-with-draft.png"), animations: "disabled" });
await sendButton.click();
const queue = page.getByRole("list", { name: "待处理消息" });
const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" });
const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" });
@@ -131,7 +140,11 @@ for (const width of [1440, 375, 320]) {
const expandMessage = longRow.getByRole("button", { name: "展开全文" });
await expect(expandMessage).toBeVisible();
await expect(expandMessage).toHaveAttribute("aria-expanded", "false");
await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toBeVisible();
await expect(queue).toBeVisible();
await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toHaveCount(0);
const queueBox = (await page.getByRole("region", { name: "待处理消息队列" }).boundingBox())!;
const editorBox = (await page.locator("form").boundingBox())!;
expect(queueBox.y + queueBox.height).toBeLessThanOrEqual(editorBox.y);
await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" });
await expandMessage.click();
await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true");
@@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
for (const width of [1440, 320]) {
test(`native user message copy preserves source text at ${width}px`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockWorkbenchContext(page);
await mockAgentApi(page);
let responseIndex = 0;
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: `copy-answer-${++responseIndex}` },
{ type: "data-final_answer", data: { content: "已核对管道。" } },
{ type: "finish", finishReason: "stop" }
])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const prompt = "能直接高亮三根管道吗";
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(prompt);
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(panel.getByText("已核对管道。", { exact: true })).toBeVisible();
const answerParagraph = panel.locator(".agent-streaming-response p").first();
await answerParagraph.click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("已核对管道。");
const text = panel.locator(".agent-panel-user-message p");
await text.click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt);
// Exact substrings retain native copy behavior.
await text.evaluate((paragraph) => {
const range = document.createRange();
range.setStart(paragraph.firstChild!, 2);
range.setEnd(paragraph.firstChild!, 6);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt.slice(2, 6));
// A selection containing another message must retain its native separators.
const expectedCrossMessage = await panel.evaluate((element) => {
const user = element.querySelector(".agent-panel-user-message p")!;
const answer = element.querySelector(".agent-streaming-response p")!;
const range = document.createRange();
range.setStart(user.firstChild!, 0);
range.setEnd(answer.firstChild!, answer.textContent!.length);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
return window.getSelection()!.toString();
});
expect(expectedCrossMessage).toContain("已核对管道。");
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(expectedCrossMessage);
// Multiline source text keeps blank lines, indentation and interior spaces.
const multiline = "第一行\n\n 第二行 内容";
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(multiline);
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const lastText = panel.locator(".agent-panel-user-message p").last();
await expect(lastText).toHaveText(multiline, { useInnerText: false });
await expect(panel.locator(".agent-streaming-response p")).toHaveCount(2);
await expect(panel.locator(".agent-streaming-response p").last()).toHaveText("已核对管道。");
await panel.evaluate((element) => {
const user = element.querySelectorAll(".agent-panel-user-message p");
const answer = element.querySelectorAll(".agent-streaming-response p");
const range = document.createRange();
range.setStart(user[user.length - 1].firstChild!, 0);
range.setEnd(answer[answer.length - 1].firstChild!, 0);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(multiline);
});
}
for (const width of [1440, 320]) {
test(`assistant rich text keeps meaningful native copy at ${width}px`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockWorkbenchContext(page);
await mockAgentApi(page);
const markdown = "首段包含**加粗**文字。\n\n第二段保留段落。\n\n```text\n first\n second\n```\n\n| 名称 | 数值 |\n| --- | --- |\n| 流量 | 12 |";
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "rich-copy-answer" },
{ type: "data-final_answer", data: { content: markdown } },
{ type: "finish", finishReason: "stop" }
])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对格式");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const response = panel.locator(".agent-streaming-response");
await expect(response.locator("table")).toBeVisible();
await response.locator("p").first().click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("首段包含加粗文字。");
for (const selector of ["p", "pre", "table"]) {
const nativeText = await response.evaluate((element, selector) => {
const nodes = element.querySelectorAll(selector);
const range = document.createRange();
if (selector === "p") {
range.setStart(nodes[0].firstChild!, 0);
range.setEnd(nodes[1].firstChild!, nodes[1].firstChild!.textContent!.length);
} else {
range.selectNodeContents(nodes[0]);
}
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
return window.getSelection()!.toString();
}, selector);
if (selector === "p") expect(nativeText).toContain("\n\n");
if (selector === "pre") expect(nativeText).toContain(" first\n second");
if (selector === "table") expect(nativeText).toContain("\t");
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(nativeText);
}
await expect(panel.getByRole("button", { name: /复制/ }).first()).toBeAttached();
});
}
+1
View File
@@ -13,6 +13,7 @@ type MockAgentApiOptions = {
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
await page.route("http://127.0.0.1:8787/health", (route) => route.fulfill({ headers: corsHeaders, json: { ok: true, ready: true } }));
const model = options.model ?? {
id: "test/model",
label: "测试模型",