feat(testing): add fixed tool simulations
This commit is contained in:
@@ -36,11 +36,12 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
BatchEvaluationResult,
|
||||
BatchRunCase,
|
||||
BatchRunSnapshot,
|
||||
BatchStartupResult,
|
||||
import {
|
||||
testExecutionErrorLabel,
|
||||
type BatchEvaluationResult,
|
||||
type BatchRunCase,
|
||||
type BatchRunSnapshot,
|
||||
type BatchStartupResult,
|
||||
} from "@/data/batch-run";
|
||||
import {
|
||||
getTestCaseValidationMessage,
|
||||
@@ -422,7 +423,7 @@ export function AutoTestLivePanel({
|
||||
) {
|
||||
return (
|
||||
<ErrorPanel
|
||||
title={state.runCase.executionError.code}
|
||||
title={testExecutionErrorLabel(state.runCase.executionError.code)}
|
||||
description={state.runCase.executionError.message}
|
||||
/>
|
||||
);
|
||||
@@ -504,7 +505,7 @@ export function AutoTestLivePanel({
|
||||
{state.phase === "error" && state.runCase?.executionError && (
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/[0.06] px-3 py-2.5 text-xs leading-5 text-amber-600 dark:text-amber-400">
|
||||
<span className="font-medium">
|
||||
{state.runCase.executionError.code}
|
||||
{testExecutionErrorLabel(state.runCase.executionError.code)}
|
||||
</span>
|
||||
<span> · {state.runCase.executionError.message}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
firstEvaluationFailureReason,
|
||||
testExecutionErrorLabel,
|
||||
type BatchEvaluationResult,
|
||||
type BatchRunCase,
|
||||
type BatchToolCallRecord,
|
||||
@@ -115,7 +116,7 @@ export function BatchCaseDetailDrawer({
|
||||
<div className="mt-2.5 rounded-2xl border border-amber-500/25 bg-amber-500/[0.06] px-4 py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-xs font-medium text-foreground">
|
||||
{item.executionError.code}
|
||||
{testExecutionErrorLabel(item.executionError.code)}
|
||||
</code>
|
||||
<span className="rounded-full border border-amber-500/25 px-2 py-0.5 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{item.executionError.stage}
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
DEFAULT_INPUT_MODE,
|
||||
getTestCaseValidationMessage,
|
||||
normalizeOverallCriteria,
|
||||
prepareFixedToolSimulations,
|
||||
TEST_CASE_INPUT_MODE_LABEL,
|
||||
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
||||
type TestCase,
|
||||
@@ -420,6 +421,7 @@ function caseToDraft(item: TestCase): CaseEditorDraft {
|
||||
inputMode: item.inputMode,
|
||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||
turns: cloneTurns(item.turns),
|
||||
toolSimulations: prepareFixedToolSimulations(item.toolSimulations),
|
||||
overallCriteria: cloneOverallCriteria(item.overallCriteria),
|
||||
};
|
||||
}
|
||||
@@ -434,6 +436,7 @@ function createEmptyCaseDraft(): CaseEditorDraft {
|
||||
inputMode: DEFAULT_INPUT_MODE,
|
||||
contextTurns: [],
|
||||
turns: [createEmptyFixedInputTurn()],
|
||||
toolSimulations: [],
|
||||
overallCriteria: [],
|
||||
};
|
||||
}
|
||||
@@ -625,6 +628,7 @@ function SuiteDetailView({
|
||||
inputMode: draft.inputMode,
|
||||
contextTurns: draft.contextTurns,
|
||||
turns: draft.turns,
|
||||
toolSimulations: draft.toolSimulations,
|
||||
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ToolSimulationEditor,
|
||||
type EditorToolOption,
|
||||
} from "@/components/test-cases/tool-simulation-editor";
|
||||
import {
|
||||
ASSERTION_TYPE_LABEL,
|
||||
KEYWORD_MATCH_MODE_LABEL,
|
||||
@@ -76,6 +80,7 @@ import {
|
||||
type TestImageRef,
|
||||
type TestCaseInputMode,
|
||||
type ToolCallExpectedBehavior,
|
||||
type ToolSimulation,
|
||||
type ToolParamAssertion,
|
||||
type ToolParamMatchMode,
|
||||
} from "@/data/test-suites";
|
||||
@@ -87,6 +92,7 @@ export type CaseEditorDraft = {
|
||||
inputMode: TestCaseInputMode;
|
||||
contextTurns: ContextTurn[];
|
||||
turns: FixedInputTurn[];
|
||||
toolSimulations: ToolSimulation[];
|
||||
overallCriteria: OverallCriterion[];
|
||||
};
|
||||
|
||||
@@ -104,15 +110,7 @@ function contextPlaceholder(role: ContextRole): string {
|
||||
return '{\n "status": "accepted"\n}';
|
||||
}
|
||||
|
||||
/** 编辑器内使用的工具选项(来自 Agent 工具库) */
|
||||
export type EditorToolOption = {
|
||||
id: string;
|
||||
functionName: string;
|
||||
label: string;
|
||||
parameters: { name: string; description: string }[];
|
||||
};
|
||||
|
||||
type EditorSectionId = "context" | "main" | "overall";
|
||||
type EditorSectionId = "context" | "main" | "tools" | "overall";
|
||||
|
||||
function editorSections(): {
|
||||
id: EditorSectionId;
|
||||
@@ -121,6 +119,7 @@ function editorSections(): {
|
||||
return [
|
||||
{ id: "context", label: "对话上下文" },
|
||||
{ id: "main", label: "固定输入" },
|
||||
{ id: "tools", label: "工具模拟" },
|
||||
{ id: "overall", label: "整体评估" },
|
||||
];
|
||||
}
|
||||
@@ -156,6 +155,7 @@ function toEditorToolOption(tool: Tool): EditorToolOption {
|
||||
id: tool.id,
|
||||
functionName: tool.functionName || tool.name,
|
||||
label: tool.name || tool.functionName,
|
||||
type: tool.type,
|
||||
parameters: toolParametersFromDefinition(tool),
|
||||
};
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export function NextReplyEditorBody({
|
||||
const sectionRefs = useRef<Record<EditorSectionId, HTMLElement | null>>({
|
||||
context: null,
|
||||
main: null,
|
||||
tools: null,
|
||||
overall: null,
|
||||
});
|
||||
const selectedAnchorRef = useRef<EditorSectionId | null>(null);
|
||||
@@ -230,7 +231,12 @@ export function NextReplyEditorBody({
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||
const activationLine = containerTop + 24;
|
||||
const sectionIds: EditorSectionId[] = ["context", "main", "overall"];
|
||||
const sectionIds: EditorSectionId[] = [
|
||||
"context",
|
||||
"main",
|
||||
"tools",
|
||||
"overall",
|
||||
];
|
||||
let nextSection: EditorSectionId = sectionIds[0];
|
||||
|
||||
for (const sectionId of sectionIds) {
|
||||
@@ -394,7 +400,10 @@ export function NextReplyEditorBody({
|
||||
|
||||
function removeTurn(turnId: string) {
|
||||
if (draft.turns.length <= 1) return;
|
||||
updateTurns(draft.turns.filter((turn) => turn.id !== turnId));
|
||||
onChange({
|
||||
...draft,
|
||||
turns: draft.turns.filter((turn) => turn.id !== turnId),
|
||||
});
|
||||
}
|
||||
|
||||
function handleTurnDragEnd(event: DragEndEvent) {
|
||||
@@ -681,6 +690,28 @@ export function NextReplyEditorBody({
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.tools = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<Wrench size={15} />}
|
||||
title="工具模拟"
|
||||
description="为外部工具配置固定返回;每次调用复用同一结果,缺少模拟时测试立即停止。"
|
||||
>
|
||||
<ToolSimulationEditor
|
||||
value={draft.toolSimulations}
|
||||
tools={tools}
|
||||
turns={draft.turns}
|
||||
onChange={(toolSimulations) =>
|
||||
onChange({ ...draft, toolSimulations })
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.overall = element;
|
||||
@@ -1094,7 +1125,7 @@ function SortableFixedInputTurn({
|
||||
>
|
||||
<Wrench
|
||||
size={15}
|
||||
className="mt-0.5 shrink-0 text-violet-400/90"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
@@ -1140,7 +1171,7 @@ function ExpectedBehaviorCard({
|
||||
className={cn(
|
||||
"group/card w-full rounded-xl border px-3 py-2.5 text-left transition-colors hover:bg-canvas-soft/60",
|
||||
behavior.type === "tool_call"
|
||||
? "border-violet-500/25 bg-violet-500/[0.04]"
|
||||
? "border-hairline bg-surface-strong/25"
|
||||
: "border-hairline bg-canvas-soft/30",
|
||||
validationMessage && "border-destructive/40",
|
||||
)}
|
||||
@@ -1168,7 +1199,7 @@ function ExpectedBehaviorCard({
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5",
|
||||
behavior.type === "tool_call"
|
||||
? "border-violet-500/30 bg-violet-500/[0.04]"
|
||||
? "border-hairline bg-surface-strong/25"
|
||||
: "border-hairline bg-canvas-soft/40",
|
||||
validationMessage && "border-destructive/40",
|
||||
)}
|
||||
@@ -1184,7 +1215,7 @@ function ExpectedBehaviorCard({
|
||||
{behavior.type === "reply" ? (
|
||||
<MessageSquareText size={14} className="shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Wrench size={14} className="shrink-0 text-violet-400/90" />
|
||||
<Wrench size={14} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{behavior.type === "reply" ? "回复要求" : "工具调用"}
|
||||
@@ -1257,7 +1288,7 @@ function BehaviorSummary({ behavior }: { behavior: ExpectedBehavior }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<Wrench size={13} className="text-violet-400/90" />
|
||||
<Wrench size={13} className="text-muted-foreground" />
|
||||
{behavior.expectation === "not_called" ? "禁止调用 · " : ""}
|
||||
{name}
|
||||
</div>
|
||||
@@ -1712,87 +1743,9 @@ function ToolCallBehaviorEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTool && behavior.expectation === "called" && (
|
||||
<div className="space-y-2 rounded-lg border border-violet-500/20 bg-background/60 p-2.5">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
Mock 工具响应
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-muted-soft">
|
||||
工具被调用后将此结果注入 pipeline,使下一轮可稳定复现。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_112px]">
|
||||
<Select
|
||||
value={behavior.mockResponse.outcome}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...behavior,
|
||||
mockResponse: {
|
||||
...behavior.mockResponse,
|
||||
outcome: value as "success" | "error",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Mock 工具响应类型"
|
||||
className="h-8 border-hairline-strong bg-background text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="success">成功响应</SelectItem>
|
||||
<SelectItem value="error">错误响应</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60000}
|
||||
step={100}
|
||||
value={behavior.mockResponse.delayMs}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
onChange({
|
||||
...behavior,
|
||||
mockResponse: {
|
||||
...behavior.mockResponse,
|
||||
delayMs: Number.isFinite(next)
|
||||
? Math.min(60000, Math.max(0, Math.round(next)))
|
||||
: 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
aria-label="Mock 工具响应延迟"
|
||||
className="h-8 border-hairline-strong bg-background text-xs"
|
||||
/>
|
||||
<span className="text-[11px] text-muted-soft">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
value={behavior.mockResponse.body}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...behavior,
|
||||
mockResponse: {
|
||||
...behavior.mockResponse,
|
||||
body: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
rows={5}
|
||||
aria-label="Mock 工具响应 JSON"
|
||||
placeholder={'{\n "status": "ok"\n}'}
|
||||
className="field-sizing-fixed min-h-[112px] resize-y border-hairline-strong bg-background font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTool && behavior.expectation === "not_called" && (
|
||||
<p className="rounded-lg border border-hairline bg-background/60 px-2.5 py-2 text-[11px] leading-4 text-muted-soft">
|
||||
本轮该工具的允许调用次数为 0;参数与 Mock 响应无需配置。
|
||||
本轮该工具的允许调用次数为 0;参数无需配置。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { MonitorSmartphone, Plus, Server, Trash2, Wrench } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createToolSimulation,
|
||||
getToolSimulationValidationMessage,
|
||||
type FixedInputTurn,
|
||||
type ToolSimulation,
|
||||
type ToolSimulationResponse,
|
||||
} from "@/data/test-suites";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EditorToolOption = {
|
||||
id: string;
|
||||
functionName: string;
|
||||
label: string;
|
||||
type: "http" | "mcp" | "client";
|
||||
parameters: { name: string; description: string }[];
|
||||
};
|
||||
|
||||
const TOOL_TYPE_LABEL: Record<EditorToolOption["type"], string> = {
|
||||
http: "服务端 · HTTP",
|
||||
mcp: "服务端 · MCP",
|
||||
client: "客户端交互",
|
||||
};
|
||||
|
||||
export function ToolSimulationEditor({
|
||||
value,
|
||||
tools,
|
||||
turns,
|
||||
onChange,
|
||||
}: {
|
||||
value: ToolSimulation[];
|
||||
tools: EditorToolOption[];
|
||||
turns: FixedInputTurn[];
|
||||
onChange: (value: ToolSimulation[]) => void;
|
||||
}) {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const configuredIds = useMemo(
|
||||
() => new Set(value.map((simulation) => simulation.toolId)),
|
||||
[value],
|
||||
);
|
||||
const availableTools = tools.filter((tool) => !configuredIds.has(tool.id));
|
||||
const turnIds = useMemo(() => new Set(turns.map((turn) => turn.id)), [turns]);
|
||||
|
||||
function addTool(tool: EditorToolOption) {
|
||||
onChange([
|
||||
...value,
|
||||
createToolSimulation({
|
||||
toolId: tool.id,
|
||||
functionName: tool.functionName,
|
||||
toolType: tool.type,
|
||||
}),
|
||||
]);
|
||||
setAddOpen(false);
|
||||
}
|
||||
|
||||
function updateSimulation(index: number, simulation: ToolSimulation) {
|
||||
onChange(
|
||||
value.map((item, itemIndex) =>
|
||||
itemIndex === index ? simulation : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{value.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft/50 px-4 py-7 text-center">
|
||||
<Wrench size={18} className="mx-auto text-muted-soft" />
|
||||
<p className="mt-2 text-xs font-medium text-foreground">
|
||||
暂未配置工具模拟
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-[11px] leading-4 text-muted-soft">
|
||||
运行中调用未配置的外部工具将立即停止当前用例。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{value.map((simulation, index) => {
|
||||
const tool =
|
||||
tools.find((candidate) => candidate.id === simulation.toolId) ??
|
||||
tools.find(
|
||||
(candidate) =>
|
||||
candidate.functionName === simulation.functionName,
|
||||
) ??
|
||||
null;
|
||||
return (
|
||||
<ToolSimulationCard
|
||||
key={simulation.toolId}
|
||||
simulation={simulation}
|
||||
tool={tool}
|
||||
validationMessage={getToolSimulationValidationMessage(
|
||||
simulation,
|
||||
turnIds,
|
||||
)}
|
||||
onChange={(next) => updateSimulation(index, next)}
|
||||
onRemove={() =>
|
||||
onChange(value.filter((_, itemIndex) => itemIndex !== index))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 rounded-full border-hairline-strong text-xs text-muted-foreground"
|
||||
disabled={availableTools.length === 0}
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加工具模拟
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-1.5">
|
||||
<div className="px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-muted-soft uppercase">
|
||||
选择工具
|
||||
</div>
|
||||
{availableTools.length === 0 ? (
|
||||
<p className="px-2.5 py-3 text-xs text-muted-foreground">
|
||||
所有可用工具均已配置
|
||||
</p>
|
||||
) : (
|
||||
availableTools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-strong"
|
||||
onClick={() => addTool(tool)}
|
||||
>
|
||||
<ToolTypeIcon type={tool.type} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{tool.label || tool.functionName}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-[11px] text-muted-soft">
|
||||
{tool.functionName}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
|
||||
{TOOL_TYPE_LABEL[tool.type]}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolSimulationCard({
|
||||
simulation,
|
||||
tool,
|
||||
validationMessage,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
simulation: ToolSimulation;
|
||||
tool: EditorToolOption | null;
|
||||
validationMessage: string | null;
|
||||
onChange: (simulation: ToolSimulation) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const toolType = tool?.type;
|
||||
const response =
|
||||
simulation.mode === "fixed"
|
||||
? simulation.response
|
||||
: simulation.steps[0]
|
||||
? responseFromLegacyStep(simulation.steps[0])
|
||||
: defaultResponse(toolType, simulation.functionName);
|
||||
|
||||
function updateFixedResponse(nextResponse: ToolSimulationResponse) {
|
||||
onChange({
|
||||
toolId: simulation.toolId,
|
||||
functionName: simulation.functionName,
|
||||
mode: "fixed",
|
||||
response: nextResponse,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-hairline bg-canvas-soft/30 p-3",
|
||||
validationMessage && "border-destructive/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-muted-foreground">
|
||||
{toolType ? <ToolTypeIcon type={toolType} /> : <Wrench size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{tool?.label || simulation.functionName}
|
||||
</span>
|
||||
<span className="rounded-full border border-hairline px-2 py-0.5 text-[10px] text-muted-soft">
|
||||
{toolType ? TOOL_TYPE_LABEL[toolType] : "工具已不可用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-soft">
|
||||
{simulation.functionName}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-7 w-7 shrink-0 text-muted-soft hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
aria-label={`删除 ${simulation.functionName} 工具模拟`}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{simulation.mode === "sequence" ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-hairline bg-surface-strong/45 px-3 py-2">
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
这是旧版顺序配置。转换后将复用第一条响应作为固定返回。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 rounded-full border border-hairline-strong px-2.5 text-[11px] text-foreground transition-colors hover:bg-background"
|
||||
onClick={() => updateFixedResponse(response)}
|
||||
>
|
||||
转为固定返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-hairline bg-background/60 p-2.5">
|
||||
<SimulationResponseFields
|
||||
value={response}
|
||||
toolType={toolType}
|
||||
ariaPrefix={`${simulation.functionName} 固定返回`}
|
||||
onChange={updateFixedResponse}
|
||||
/>
|
||||
<p className="mt-2 text-[11px] leading-4 text-muted-soft">
|
||||
每次调用都返回此结果;调用次数仍由预期行为单独判断。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{validationMessage ? (
|
||||
<p className="mt-2 text-[11px] text-destructive" role="alert">
|
||||
{validationMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SimulationResponseFields({
|
||||
value,
|
||||
toolType,
|
||||
ariaPrefix,
|
||||
onChange,
|
||||
}: {
|
||||
value: ToolSimulationResponse;
|
||||
toolType: EditorToolOption["type"] | undefined;
|
||||
ariaPrefix: string;
|
||||
onChange: (value: ToolSimulationResponse) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_112px]">
|
||||
<Select
|
||||
value={value.outcome}
|
||||
onValueChange={(outcome) =>
|
||||
onChange({ ...value, outcome: outcome as "success" | "error" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`${ariaPrefix}状态`}
|
||||
className="h-8 border-hairline-strong bg-background text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="success">成功响应</SelectItem>
|
||||
<SelectItem value="error">错误响应</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60000}
|
||||
step={100}
|
||||
value={value.delayMs}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
onChange({
|
||||
...value,
|
||||
delayMs: Number.isFinite(next)
|
||||
? Math.min(60000, Math.max(0, Math.round(next)))
|
||||
: 0,
|
||||
});
|
||||
}}
|
||||
aria-label={`${ariaPrefix}延迟`}
|
||||
className="h-8 border-hairline-strong bg-background text-xs"
|
||||
/>
|
||||
<span className="text-[11px] text-muted-soft">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value.body}
|
||||
onChange={(event) => onChange({ ...value, body: event.target.value })}
|
||||
rows={5}
|
||||
aria-label={`${ariaPrefix} JSON`}
|
||||
placeholder={
|
||||
toolType === "client"
|
||||
? '{\n "action": "selected",\n "value": "basic"\n}'
|
||||
: '{\n "status": "ok"\n}'
|
||||
}
|
||||
className="field-sizing-fixed min-h-[112px] resize-y border-hairline-strong bg-background font-mono text-xs"
|
||||
/>
|
||||
{toolType === "client" ? (
|
||||
<p className="text-[11px] leading-4 text-muted-soft">
|
||||
JSON 表示客户端回传给 Agent 的用户操作,例如选择、关闭或取消。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultResponse(
|
||||
toolType: EditorToolOption["type"] | undefined,
|
||||
functionName: string,
|
||||
): ToolSimulationResponse {
|
||||
return {
|
||||
outcome: "success",
|
||||
body:
|
||||
toolType === "client"
|
||||
? functionName === "show_message"
|
||||
? '{\n "action": "confirmed"\n}'
|
||||
: '{\n "action": "selected",\n "value": ""\n}'
|
||||
: '{\n "status": "ok"\n}',
|
||||
delayMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function responseFromLegacyStep(
|
||||
step: Extract<ToolSimulation, { mode: "sequence" }>["steps"][number],
|
||||
): ToolSimulationResponse {
|
||||
return {
|
||||
outcome: step.outcome,
|
||||
body: step.body,
|
||||
delayMs: step.delayMs,
|
||||
};
|
||||
}
|
||||
|
||||
function ToolTypeIcon({ type }: { type: EditorToolOption["type"] }) {
|
||||
if (type === "client") {
|
||||
return <MonitorSmartphone size={14} className="shrink-0" />;
|
||||
}
|
||||
return <Server size={14} className="shrink-0" />;
|
||||
}
|
||||
@@ -31,6 +31,16 @@ export type BatchExecutionError = {
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
const TEST_EXECUTION_ERROR_LABEL: Record<string, string> = {
|
||||
UNMOCKED_TOOL_CALL: "工具未配置模拟",
|
||||
TOOL_SIMULATION_EXHAUSTED: "工具模拟响应已耗尽",
|
||||
TOOL_SIMULATION_SCOPE_MISMATCH: "工具模拟阶段不匹配",
|
||||
};
|
||||
|
||||
export function testExecutionErrorLabel(code: string): string {
|
||||
return TEST_EXECUTION_ERROR_LABEL[code] ?? code;
|
||||
}
|
||||
|
||||
export type BatchEvaluationResult = {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -56,13 +56,37 @@ export type ReplyExpectedBehavior = {
|
||||
llmCriteria: string;
|
||||
};
|
||||
|
||||
type ToolMockResponse = {
|
||||
export type ToolSimulationResponse = {
|
||||
outcome: "success" | "error";
|
||||
/** JSON 文本,运行时会作为工具返回值注入 pipeline */
|
||||
body: string;
|
||||
delayMs: number;
|
||||
};
|
||||
|
||||
export type ToolSimulationScope =
|
||||
| { phase: "any" }
|
||||
| { phase: "startup" }
|
||||
| { phase: "turn"; turnId: string };
|
||||
|
||||
export type ToolSimulationStep = ToolSimulationResponse & {
|
||||
id: string;
|
||||
scope: ToolSimulationScope;
|
||||
};
|
||||
|
||||
export type ToolSimulation = {
|
||||
toolId: string;
|
||||
functionName: string;
|
||||
} & (
|
||||
| {
|
||||
mode: "fixed";
|
||||
response: ToolSimulationResponse;
|
||||
}
|
||||
| {
|
||||
mode: "sequence";
|
||||
steps: ToolSimulationStep[];
|
||||
}
|
||||
);
|
||||
|
||||
/** 预期行为:工具调用或禁止调用指定工具 */
|
||||
export type ToolCallExpectedBehavior = {
|
||||
id: string;
|
||||
@@ -74,8 +98,6 @@ export type ToolCallExpectedBehavior = {
|
||||
maxCalls: number | null;
|
||||
/** 仅包含用户主动配置的参数;未列出的参数不校验 */
|
||||
paramAssertions: ToolParamAssertion[];
|
||||
/** 调用工具后注入 pipeline 的稳定返回值 */
|
||||
mockResponse: ToolMockResponse;
|
||||
};
|
||||
|
||||
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
|
||||
@@ -132,6 +154,8 @@ export type TestCase = {
|
||||
contextTurns: ContextTurn[];
|
||||
/** 固定脚本轮次 */
|
||||
turns: FixedInputTurn[];
|
||||
/** 测试用例级工具模拟;与工具调用断言相互独立 */
|
||||
toolSimulations: ToolSimulation[];
|
||||
/** 整段对话业务目标预期;所有标准均通过才算用例通过 */
|
||||
overallCriteria: OverallCriterion[];
|
||||
/** 套件内排序,越小越靠前 */
|
||||
@@ -148,7 +172,11 @@ export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
|
||||
export function getTestCaseValidationMessage(
|
||||
item: Pick<
|
||||
TestCase,
|
||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
||||
| "inputMode"
|
||||
| "contextTurns"
|
||||
| "turns"
|
||||
| "toolSimulations"
|
||||
| "overallCriteria"
|
||||
>,
|
||||
): string | null {
|
||||
if (item.inputMode !== "fixed_script_text") {
|
||||
@@ -248,6 +276,49 @@ export function getTestCaseValidationMessage(
|
||||
}
|
||||
}
|
||||
}
|
||||
const simulationToolIds = new Set<string>();
|
||||
const simulationStepIds = new Set<string>();
|
||||
const turnIds = new Set(item.turns.map((turn) => turn.id));
|
||||
for (let index = 0; index < item.toolSimulations.length; index += 1) {
|
||||
const simulation = item.toolSimulations[index];
|
||||
if (simulationToolIds.has(simulation.toolId)) {
|
||||
return `工具模拟 ${index + 1}:同一工具只能配置一条模拟`;
|
||||
}
|
||||
simulationToolIds.add(simulation.toolId);
|
||||
const message = getToolSimulationValidationMessage(
|
||||
simulation,
|
||||
turnIds,
|
||||
simulationStepIds,
|
||||
);
|
||||
if (message) return `工具模拟 ${index + 1}:${message}`;
|
||||
}
|
||||
for (let turnIndex = 0; turnIndex < item.turns.length; turnIndex += 1) {
|
||||
const turn = item.turns[turnIndex];
|
||||
for (const behavior of turn.behaviors) {
|
||||
if (behavior.type !== "tool_call" || behavior.expectation !== "called") {
|
||||
continue;
|
||||
}
|
||||
const simulation = item.toolSimulations.find(
|
||||
(candidate) => candidate.toolId === behavior.toolId,
|
||||
);
|
||||
if (!simulation) {
|
||||
return `第 ${turnIndex + 1} 轮工具 ${behavior.functionName} 缺少工具模拟`;
|
||||
}
|
||||
if (simulation.functionName !== behavior.functionName) {
|
||||
return `第 ${turnIndex + 1} 轮工具 ${behavior.functionName} 的断言与模拟不匹配`;
|
||||
}
|
||||
if (
|
||||
simulation.mode === "sequence" &&
|
||||
!simulation.steps.some(
|
||||
(step) =>
|
||||
step.scope.phase === "any" ||
|
||||
(step.scope.phase === "turn" && step.scope.turnId === turn.id),
|
||||
)
|
||||
) {
|
||||
return `第 ${turnIndex + 1} 轮工具 ${behavior.functionName} 缺少覆盖本轮的模拟步骤`;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < item.overallCriteria.length; index += 1) {
|
||||
const criterion = item.overallCriteria[index];
|
||||
if (!criterion.name.trim()) {
|
||||
@@ -269,7 +340,11 @@ export function getTestCaseValidationMessage(
|
||||
export function isTestCaseRunnable(
|
||||
item: Pick<
|
||||
TestCase,
|
||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
||||
| "inputMode"
|
||||
| "contextTurns"
|
||||
| "turns"
|
||||
| "toolSimulations"
|
||||
| "overallCriteria"
|
||||
>,
|
||||
): boolean {
|
||||
return getTestCaseValidationMessage(item) === null;
|
||||
@@ -312,7 +387,55 @@ export const TOOL_PARAM_MATCH_MODE_LABEL: Record<ToolParamMatchMode, string> = {
|
||||
};
|
||||
|
||||
export const OVERALL_CRITERION_TEXT_MAX = 2000;
|
||||
const TOOL_MOCK_DELAY_MAX_MS = 60_000;
|
||||
const TOOL_SIMULATION_DELAY_MAX_MS = 60_000;
|
||||
|
||||
function getSimulationResponseValidationMessage(
|
||||
response: ToolSimulationResponse,
|
||||
): string | null {
|
||||
if (!response.body.trim()) return "请填写工具返回值";
|
||||
try {
|
||||
JSON.parse(response.body);
|
||||
} catch {
|
||||
return "工具返回值必须是有效 JSON";
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(response.delayMs) ||
|
||||
response.delayMs < 0 ||
|
||||
response.delayMs > TOOL_SIMULATION_DELAY_MAX_MS
|
||||
) {
|
||||
return "模拟延迟需为 0–60000 毫秒的整数";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getToolSimulationValidationMessage(
|
||||
simulation: ToolSimulation,
|
||||
turnIds: Set<string>,
|
||||
seenStepIds: Set<string> = new Set(),
|
||||
): string | null {
|
||||
if (!simulation.toolId.trim() || !simulation.functionName.trim()) {
|
||||
return "请选择有效工具";
|
||||
}
|
||||
if (simulation.mode === "fixed") {
|
||||
return getSimulationResponseValidationMessage(simulation.response);
|
||||
}
|
||||
if (simulation.steps.length === 0) return "顺序返回至少需要一个步骤";
|
||||
for (let index = 0; index < simulation.steps.length; index += 1) {
|
||||
const step = simulation.steps[index];
|
||||
if (!step.id.trim()) return `步骤 ${index + 1} 缺少 ID`;
|
||||
if (seenStepIds.has(step.id)) return `步骤 ID 重复:${step.id}`;
|
||||
seenStepIds.add(step.id);
|
||||
if (
|
||||
step.scope.phase === "turn" &&
|
||||
!turnIds.has(step.scope.turnId)
|
||||
) {
|
||||
return `步骤 ${index + 1} 引用了不存在的轮次`;
|
||||
}
|
||||
const message = getSimulationResponseValidationMessage(step);
|
||||
if (message) return `步骤 ${index + 1}:${message}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getExpectedBehaviorValidationMessage(
|
||||
behavior: ExpectedBehavior,
|
||||
@@ -353,21 +476,6 @@ export function getExpectedBehaviorValidationMessage(
|
||||
(item) => !item.name.trim() || !item.value.trim(),
|
||||
);
|
||||
if (emptyParam) return "工具参数断言的名称和值不能为空";
|
||||
if (!behavior.mockResponse.body.trim()) {
|
||||
return "请填写 Mock 工具返回值";
|
||||
}
|
||||
try {
|
||||
JSON.parse(behavior.mockResponse.body);
|
||||
} catch {
|
||||
return "Mock 工具返回值必须是有效 JSON";
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(behavior.mockResponse.delayMs) ||
|
||||
behavior.mockResponse.delayMs < 0 ||
|
||||
behavior.mockResponse.delayMs > TOOL_MOCK_DELAY_MAX_MS
|
||||
) {
|
||||
return "Mock 延迟需为 0–60000 毫秒的整数";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -387,6 +495,29 @@ function nextCriterionId() {
|
||||
return newEditorId("criterion");
|
||||
}
|
||||
|
||||
export function createToolSimulation(input: {
|
||||
toolId: string;
|
||||
functionName: string;
|
||||
toolType: "http" | "mcp" | "client";
|
||||
}): ToolSimulation {
|
||||
const body =
|
||||
input.toolType === "client"
|
||||
? input.functionName === "show_message"
|
||||
? '{\n "action": "confirmed"\n}'
|
||||
: '{\n "action": "selected",\n "value": ""\n}'
|
||||
: '{\n "status": "ok"\n}';
|
||||
return {
|
||||
toolId: input.toolId,
|
||||
functionName: input.functionName,
|
||||
mode: "fixed",
|
||||
response: {
|
||||
outcome: "success",
|
||||
body,
|
||||
delayMs: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOverallCriterion(
|
||||
name = "新的评估标准",
|
||||
): OverallCriterion {
|
||||
@@ -453,11 +584,6 @@ export function createToolCallBehavior(input?: {
|
||||
minCalls: 1,
|
||||
maxCalls: null,
|
||||
paramAssertions: [],
|
||||
mockResponse: {
|
||||
outcome: "success",
|
||||
body: '{\n "status": "ok"\n}',
|
||||
delayMs: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -482,11 +608,6 @@ function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
|
||||
minCalls: behavior.minCalls,
|
||||
maxCalls: behavior.maxCalls,
|
||||
paramAssertions: behavior.paramAssertions.map((item) => ({ ...item })),
|
||||
mockResponse: {
|
||||
outcome: behavior.mockResponse.outcome,
|
||||
body: behavior.mockResponse.body,
|
||||
delayMs: behavior.mockResponse.delayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -499,6 +620,39 @@ export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function prepareFixedToolSimulations(
|
||||
values: ToolSimulation[] | null | undefined,
|
||||
): ToolSimulation[] {
|
||||
return (values ?? []).map((simulation) =>
|
||||
simulation.mode === "fixed"
|
||||
? {
|
||||
toolId: simulation.toolId,
|
||||
functionName: simulation.functionName,
|
||||
mode: "fixed",
|
||||
response: { ...simulation.response },
|
||||
}
|
||||
: {
|
||||
toolId: simulation.toolId,
|
||||
functionName: simulation.functionName,
|
||||
mode: "fixed",
|
||||
response: simulation.steps[0]
|
||||
? {
|
||||
outcome: simulation.steps[0].outcome,
|
||||
body: simulation.steps[0].body,
|
||||
delayMs: simulation.steps[0].delayMs,
|
||||
}
|
||||
: {
|
||||
outcome: "success",
|
||||
body:
|
||||
simulation.functionName === "show_message"
|
||||
? '{\n "action": "confirmed"\n}'
|
||||
: '{\n "status": "ok"\n}',
|
||||
delayMs: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(value?: string | null) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
TestCase,
|
||||
TestCaseInputMode,
|
||||
TestSuite,
|
||||
ToolSimulation,
|
||||
} from "@/data/test-suites";
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
||||
@@ -851,6 +852,7 @@ export type TestCaseWrite = {
|
||||
inputMode: TestCaseInputMode;
|
||||
contextTurns: ContextTurn[];
|
||||
turns: FixedInputTurn[];
|
||||
toolSimulations: ToolSimulation[];
|
||||
overallCriteria: OverallCriterion[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user