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,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);
});
}