fix: complete agent question interactions
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentMessageDetails } from "./agent-message-details";
|
||||
import type { AgentQuestionInfo, AgentQuestionRequest } from "../types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AgentMessageDetails question answers", () => {
|
||||
it("submits a custom answer instead of the selected option for a single-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析范围",
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{ label: "雨污混接分析", description: "检查混接问题" },
|
||||
{ label: "水质异常分析", description: "检查水质指标" }
|
||||
]
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /雨污混接分析/ }));
|
||||
await user.click(screen.getByRole("radio", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "检查高峰时段压力");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["检查高峰时段压力"]]
|
||||
);
|
||||
});
|
||||
|
||||
it("combines selected options with a custom answer for a multiple-choice question", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplyQuestion = vi.fn();
|
||||
|
||||
renderQuestion(
|
||||
{
|
||||
header: "分析内容",
|
||||
question: "需要包含哪些内容?",
|
||||
options: [
|
||||
{ label: "压力", description: "分析节点压力" },
|
||||
{ label: "流量", description: "分析管段流量" }
|
||||
],
|
||||
multiple: true,
|
||||
custom: true
|
||||
},
|
||||
onReplyQuestion
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /压力/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "自定义回答" }));
|
||||
await user.type(screen.getByPlaceholderText("输入自定义回答"), "补充水龄");
|
||||
await user.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
|
||||
expect(onReplyQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: "question-1" }),
|
||||
[["压力", "补充水龄"]]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderQuestion(
|
||||
question: AgentQuestionInfo,
|
||||
onReplyQuestion: (request: AgentQuestionRequest, answers: string[][]) => void
|
||||
) {
|
||||
render(
|
||||
<AgentMessageDetails
|
||||
message={{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "需要补充信息",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [question],
|
||||
createdAt: 100,
|
||||
status: "pending"
|
||||
}
|
||||
]
|
||||
}}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -574,7 +574,7 @@ function QuestionRequestCard({
|
||||
key={`${request.requestId}-${questionIndex}`}
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
|
||||
value={draftAnswers[questionIndex] ?? { selected: [], customSelected: false, custom: "" }}
|
||||
disabled={!actionable || submitting}
|
||||
onChange={(nextValue) =>
|
||||
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
|
||||
@@ -633,6 +633,7 @@ function QuestionRequestCard({
|
||||
|
||||
type QuestionDraftAnswer = {
|
||||
selected: string[];
|
||||
customSelected: boolean;
|
||||
custom: string;
|
||||
};
|
||||
|
||||
@@ -649,8 +650,11 @@ function QuestionInput({
|
||||
disabled: boolean;
|
||||
onChange: (value: QuestionDraftAnswer) => void;
|
||||
}) {
|
||||
const name = `agent-question-${questionIndex}-${question.question}`;
|
||||
const showCustomInput = question.custom || question.options.length === 0;
|
||||
const inputId = useId();
|
||||
const name = `agent-question-${questionIndex}-${inputId}`;
|
||||
const customEnabled = question.custom !== false;
|
||||
const showCustomChoice = customEnabled && question.options.length > 0;
|
||||
const showCustomInput = customEnabled && value.customSelected;
|
||||
|
||||
const toggleOption = (label: string, checked: boolean) => {
|
||||
if (question.multiple) {
|
||||
@@ -662,21 +666,36 @@ function QuestionInput({
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
selected: checked ? [label] : []
|
||||
selected: checked ? [label] : [],
|
||||
customSelected: false
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCustom = (checked: boolean) => {
|
||||
onChange({
|
||||
...value,
|
||||
selected: question.multiple ? value.selected : [],
|
||||
customSelected: checked
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="leading-5 text-slate-700">{question.question}</p>
|
||||
{question.options.length ? (
|
||||
{question.options.length || showCustomChoice ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option) => {
|
||||
const checked = value.selected.includes(option.label);
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
|
||||
checked
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
@@ -695,12 +714,34 @@ function QuestionInput({
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{showCustomChoice ? (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
|
||||
value.customSelected
|
||||
? "border-blue-300 bg-blue-50/70 text-blue-700"
|
||||
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70",
|
||||
disabled && "cursor-default opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
className="h-3.5 w-3.5 accent-blue-600"
|
||||
checked={value.customSelected}
|
||||
disabled={disabled}
|
||||
onChange={(event) => toggleCustom(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="font-semibold text-slate-700">自定义回答</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
aria-label="自定义回答内容"
|
||||
placeholder="输入自定义回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
|
||||
@@ -711,16 +752,25 @@ function QuestionInput({
|
||||
}
|
||||
|
||||
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
|
||||
return request.questions.map((question, index) => ({
|
||||
selected: request.answers?.[index] ?? [],
|
||||
custom: ""
|
||||
}));
|
||||
return request.questions.map((question, index) => {
|
||||
const optionLabels = new Set(question.options.map((option) => option.label));
|
||||
const savedAnswers = request.answers?.[index] ?? [];
|
||||
const selected = savedAnswers.filter((answer) => optionLabels.has(answer));
|
||||
const customAnswers = savedAnswers.filter((answer) => !optionLabels.has(answer));
|
||||
const customEnabled = question.custom !== false;
|
||||
|
||||
return {
|
||||
selected,
|
||||
customSelected: customEnabled && (question.options.length === 0 || customAnswers.length > 0),
|
||||
custom: customAnswers.join(";")
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
|
||||
return draftAnswers.map((answer) => {
|
||||
const custom = answer.custom.trim();
|
||||
return custom ? [...answer.selected, custom] : answer.selected;
|
||||
return answer.customSelected && custom ? [...answer.selected, custom] : answer.selected;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,33 @@ describe("agent session state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an actionable question id when its tool placeholder arrives later", () => {
|
||||
const questions = upsertQuestion(undefined, {
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 100
|
||||
});
|
||||
const withPlaceholder = upsertQuestion(questions, {
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
questions: [{ header: "范围", question: "选择范围", options: [] }],
|
||||
created_at: 120
|
||||
});
|
||||
const rejected = applyQuestionResponse(withPlaceholder, {
|
||||
request_id: "question-1",
|
||||
rejected: true
|
||||
});
|
||||
|
||||
expect(withPlaceholder).toHaveLength(1);
|
||||
expect(rejected?.[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
status: "rejected"
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes open assistant state after abort", () => {
|
||||
const message: AgentChatMessage = {
|
||||
id: "assistant-1",
|
||||
|
||||
@@ -183,11 +183,24 @@ export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, da
|
||||
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
|
||||
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
const current = next[index];
|
||||
const currentIsActionable = Boolean(current.tool?.callID && current.requestId !== current.tool.callID);
|
||||
const nextIsToolPlaceholder = Boolean(nextItem.tool?.callID && nextItem.requestId === nextItem.tool.callID);
|
||||
|
||||
next[index] =
|
||||
currentIsActionable && nextIsToolPlaceholder
|
||||
? {
|
||||
...current,
|
||||
sessionId: nextItem.sessionId,
|
||||
questions: nextItem.questions,
|
||||
tool: nextItem.tool ?? current.tool,
|
||||
createdAt: nextItem.createdAt
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
...nextItem,
|
||||
status: current.status === "submitting" ? "submitting" : nextItem.status
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test("submits a custom answer with the actionable id after a later tool placeholder", async ({
|
||||
page
|
||||
}) => {
|
||||
const replies: Array<{ pathname: string; body: unknown }> = [];
|
||||
await mockQuestionApi(page, replies);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
|
||||
await prompt.fill("测试 question 自定义回答");
|
||||
await page
|
||||
.getByRole("button", { name: "发送 Agent 指令" })
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
await panel.getByRole("radio", { name: "自定义回答" }).click();
|
||||
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
|
||||
await panel.getByRole("button", { name: "提交回答" }).click();
|
||||
|
||||
await expect.poll(() => replies).toEqual([
|
||||
{
|
||||
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
||||
body: {
|
||||
session_id: "question-session",
|
||||
answers: [["分析高峰时段压力"]]
|
||||
}
|
||||
}
|
||||
]);
|
||||
await expect(panel.getByText("已回答", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "提交回答" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps the custom answer control within a 375px Agent sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await mockQuestionApi(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
const prompt = panel.getByPlaceholder(/输入调度问题/);
|
||||
await prompt.fill("测试窄屏 question");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await panel.getByRole("radio", { name: "自定义回答" }).click();
|
||||
|
||||
const textarea = panel.getByPlaceholder("输入自定义回答");
|
||||
await expect(textarea).toBeVisible();
|
||||
const box = await textarea.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
|
||||
});
|
||||
|
||||
async function mockQuestionApi(
|
||||
page: Page,
|
||||
replies: Array<{ pathname: string; body: unknown }>
|
||||
) {
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname.endsWith("/stream")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
},
|
||||
body: createQuestionStream()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/question/question-1/reply")) {
|
||||
replies.push({
|
||||
pathname,
|
||||
body: request.postDataJSON()
|
||||
});
|
||||
await route.fulfill({ json: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/models")) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
default_model: "test/model",
|
||||
models: [
|
||||
{
|
||||
id: "test/model",
|
||||
label: "测试模型",
|
||||
description: "Question 交互测试",
|
||||
icon: "bolt"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/sessions")) {
|
||||
await route.fulfill({ json: { sessions: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ json: {} });
|
||||
});
|
||||
}
|
||||
|
||||
function createQuestionStream() {
|
||||
const question = {
|
||||
session_id: "question-session",
|
||||
questions: [
|
||||
{
|
||||
header: "分析范围",
|
||||
question: "你想分析哪一部分?",
|
||||
options: [
|
||||
{
|
||||
label: "雨污混接分析",
|
||||
description: "检查混接问题"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
tool: {
|
||||
messageID: "assistant-question",
|
||||
callID: "call-1"
|
||||
}
|
||||
};
|
||||
const parts = [
|
||||
{ type: "start", messageId: "assistant-question" },
|
||||
{ type: "text-start", id: "answer" },
|
||||
{ type: "text-delta", id: "answer", delta: "请选择分析范围。" },
|
||||
{
|
||||
type: "data-question_request",
|
||||
id: "question-1",
|
||||
data: {
|
||||
...question,
|
||||
request_id: "question-1",
|
||||
created_at: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "data-question_request",
|
||||
id: "call-1",
|
||||
data: {
|
||||
...question,
|
||||
request_id: "call-1",
|
||||
created_at: 120
|
||||
}
|
||||
},
|
||||
{ type: "text-end", id: "answer" },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
];
|
||||
|
||||
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
|
||||
}
|
||||
Reference in New Issue
Block a user