feat(testing): add fixed tool simulations
This commit is contained in:
@@ -154,6 +154,14 @@ async def create_test_run(
|
|||||||
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
|
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
|
||||||
definitions_by_id[row.id] = definition
|
definitions_by_id[row.id] = definition
|
||||||
has_image_input = has_image_input or bool(image_asset_ids(definition))
|
has_image_input = has_image_input or bool(image_asset_ids(definition))
|
||||||
|
for simulation in definition.tool_simulations:
|
||||||
|
tool = tools_by_id.get(simulation.tool_id)
|
||||||
|
if tool is None or tool.function_name != simulation.function_name:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"用例“{row.name}”引用的工具 {simulation.function_name} "
|
||||||
|
"不属于当前被测助手",
|
||||||
|
)
|
||||||
for turn in definition.turns:
|
for turn in definition.turns:
|
||||||
for behavior in turn.behaviors:
|
for behavior in turn.behaviors:
|
||||||
if behavior.type != "tool_call":
|
if behavior.type != "tool_call":
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from models import (
|
|||||||
RuntimeToolBinding,
|
RuntimeToolBinding,
|
||||||
)
|
)
|
||||||
from services.node_specs import copy_graph, graph_references
|
from services.node_specs import copy_graph, graph_references
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@@ -135,24 +135,56 @@ def _runtime_tool_ids(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _implicit_startup_tool_names(startup: dict | None) -> list[str]:
|
||||||
|
"""Return platform client tools used by built-in startup experiences.
|
||||||
|
|
||||||
|
A confirmation opening is configured directly on the assistant instead of
|
||||||
|
through an AssistantToolBinding, but text tests still need to simulate the
|
||||||
|
underlying client interaction explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
value = startup or {}
|
||||||
|
opening_mode = value.get("opening_mode") or value.get("openingMode")
|
||||||
|
opening_message = value.get("opening_message") or value.get("openingMessage")
|
||||||
|
if opening_mode == "confirmation" and isinstance(opening_message, dict):
|
||||||
|
return ["show_message"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _tools_for(
|
async def _tools_for(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
assistant: Assistant,
|
assistant: Assistant,
|
||||||
tool_ids: list[str],
|
tool_ids: list[str],
|
||||||
|
implicit_function_names: list[str] | None = None,
|
||||||
) -> list[RuntimeTool]:
|
) -> list[RuntimeTool]:
|
||||||
if assistant.type not in {"prompt", "workflow"} or not tool_ids:
|
function_names = list(dict.fromkeys(implicit_function_names or []))
|
||||||
|
if assistant.type not in {"prompt", "workflow"} or not (
|
||||||
|
tool_ids or function_names
|
||||||
|
):
|
||||||
return []
|
return []
|
||||||
|
identity_filters = []
|
||||||
|
if tool_ids:
|
||||||
|
identity_filters.append(Tool.id.in_(tool_ids))
|
||||||
|
if function_names:
|
||||||
|
identity_filters.append(Tool.function_name.in_(function_names))
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Tool)
|
select(Tool)
|
||||||
.where(
|
.where(
|
||||||
Tool.id.in_(tool_ids),
|
or_(*identity_filters),
|
||||||
Tool.status == "active",
|
Tool.status == "active",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
tool_by_id = {tool.id: tool for tool in rows}
|
tool_by_id = {tool.id: tool for tool in rows}
|
||||||
|
tool_by_function = {tool.function_name: tool for tool in rows}
|
||||||
tools = [tool_by_id[tool_id] for tool_id in tool_ids if tool_id in tool_by_id]
|
tools = [tool_by_id[tool_id] for tool_id in tool_ids if tool_id in tool_by_id]
|
||||||
|
seen_tool_ids = {tool.id for tool in tools}
|
||||||
|
for function_name in function_names:
|
||||||
|
tool = tool_by_function.get(function_name)
|
||||||
|
if tool is not None and tool.id not in seen_tool_ids:
|
||||||
|
tools.append(tool)
|
||||||
|
seen_tool_ids.add(tool.id)
|
||||||
server_ids = {
|
server_ids = {
|
||||||
str(tool.mcp_server_id)
|
str(tool.mcp_server_id)
|
||||||
for tool in tools
|
for tool in tools
|
||||||
@@ -268,7 +300,12 @@ async def resolve_runtime_config(
|
|||||||
|
|
||||||
prompt_tool_bindings = await _bound_tools(session, assistant.id)
|
prompt_tool_bindings = await _bound_tools(session, assistant.id)
|
||||||
runtime_tool_ids = _runtime_tool_ids(prompt_tool_bindings, assistant.startup or {})
|
runtime_tool_ids = _runtime_tool_ids(prompt_tool_bindings, assistant.startup or {})
|
||||||
runtime_tools = await _tools_for(session, assistant, runtime_tool_ids)
|
runtime_tools = await _tools_for(
|
||||||
|
session,
|
||||||
|
assistant,
|
||||||
|
runtime_tool_ids,
|
||||||
|
_implicit_startup_tool_names(assistant.startup or {}),
|
||||||
|
)
|
||||||
|
|
||||||
return AssistantConfig(
|
return AssistantConfig(
|
||||||
name=assistant.name,
|
name=assistant.name,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Test-only tool executor that never reaches external side effects."""
|
"""Strict, deterministic tool simulations for text test runs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,25 +9,141 @@ from typing import Any
|
|||||||
from models import RuntimeTool, RuntimeToolBinding
|
from models import RuntimeTool, RuntimeToolBinding
|
||||||
from services.runtime_variables import DynamicVariableStore
|
from services.runtime_variables import DynamicVariableStore
|
||||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
from test_schemas import ToolCallExpectedBehavior
|
from test_schemas import ToolSimulation, ToolSimulationResponse, ToolSimulationStep
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_SIMULATION_ERROR_CODES = {
|
||||||
|
"UNMOCKED_TOOL_CALL",
|
||||||
|
"TOOL_SIMULATION_EXHAUSTED",
|
||||||
|
"TOOL_SIMULATION_SCOPE_MISMATCH",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationError(ToolExecutionError):
|
||||||
|
"""A non-retryable test configuration error raised at tool-call time."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str):
|
||||||
|
self.code = code
|
||||||
|
super().__init__(f"{code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationResolver:
|
||||||
|
"""Resolve one case's fixed or ordered simulated tool responses."""
|
||||||
|
|
||||||
|
def __init__(self, simulations: list[ToolSimulation]):
|
||||||
|
self._by_tool_id = {item.tool_id: item for item in simulations}
|
||||||
|
self._by_function = {item.function_name: item for item in simulations}
|
||||||
|
self._positions = {item.tool_id: 0 for item in simulations}
|
||||||
|
self._phase = "startup"
|
||||||
|
self._turn_id: str | None = None
|
||||||
|
self._failure: ToolSimulationError | None = None
|
||||||
|
|
||||||
|
def enter_startup(self) -> None:
|
||||||
|
self._phase = "startup"
|
||||||
|
self._turn_id = None
|
||||||
|
|
||||||
|
def enter_turn(self, turn_id: str) -> None:
|
||||||
|
self._phase = "turn"
|
||||||
|
self._turn_id = turn_id
|
||||||
|
|
||||||
|
async def resolve(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tool_id: str | None,
|
||||||
|
function_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
simulation = (
|
||||||
|
self._by_tool_id.get(tool_id or "")
|
||||||
|
or self._by_function.get(function_name)
|
||||||
|
)
|
||||||
|
if simulation is None:
|
||||||
|
raise self._record_failure(
|
||||||
|
"UNMOCKED_TOOL_CALL",
|
||||||
|
f"工具 {function_name} 未配置工具模拟",
|
||||||
|
)
|
||||||
|
|
||||||
|
response: ToolSimulationResponse | ToolSimulationStep
|
||||||
|
if simulation.mode == "fixed":
|
||||||
|
response = simulation.response
|
||||||
|
else:
|
||||||
|
position = self._positions[simulation.tool_id]
|
||||||
|
if position >= len(simulation.steps):
|
||||||
|
raise self._record_failure(
|
||||||
|
"TOOL_SIMULATION_EXHAUSTED",
|
||||||
|
f"工具 {function_name} 的模拟响应已耗尽(第 {position + 1} 次调用)",
|
||||||
|
)
|
||||||
|
step = simulation.steps[position]
|
||||||
|
if not self._scope_matches(step):
|
||||||
|
raise self._record_failure(
|
||||||
|
"TOOL_SIMULATION_SCOPE_MISMATCH",
|
||||||
|
(
|
||||||
|
f"工具 {function_name} 的第 {position + 1} 条模拟步骤"
|
||||||
|
f"期望发生在{self._scope_label(step)},"
|
||||||
|
f"实际发生在{self._current_scope_label()}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = step
|
||||||
|
self._positions[simulation.tool_id] = position + 1
|
||||||
|
|
||||||
|
if response.delay_ms:
|
||||||
|
await asyncio.sleep(response.delay_ms / 1000)
|
||||||
|
return self._normalized_result(response)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failure(self) -> ToolSimulationError | None:
|
||||||
|
return self._failure
|
||||||
|
|
||||||
|
def raise_if_failed(self) -> None:
|
||||||
|
"""Re-raise errors swallowed by a higher-level client message stage."""
|
||||||
|
|
||||||
|
if self._failure is not None:
|
||||||
|
raise self._failure
|
||||||
|
|
||||||
|
def _record_failure(self, code: str, message: str) -> ToolSimulationError:
|
||||||
|
error = ToolSimulationError(code, message)
|
||||||
|
if self._failure is None:
|
||||||
|
self._failure = error
|
||||||
|
return error
|
||||||
|
|
||||||
|
def _scope_matches(self, step: ToolSimulationStep) -> bool:
|
||||||
|
if step.scope.phase == "any":
|
||||||
|
return True
|
||||||
|
if step.scope.phase == "startup":
|
||||||
|
return self._phase == "startup"
|
||||||
|
return self._phase == "turn" and step.scope.turn_id == self._turn_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scope_label(step: ToolSimulationStep) -> str:
|
||||||
|
if step.scope.phase == "any":
|
||||||
|
return "任意阶段"
|
||||||
|
if step.scope.phase == "startup":
|
||||||
|
return "启动阶段"
|
||||||
|
return f"轮次 {step.scope.turn_id}"
|
||||||
|
|
||||||
|
def _current_scope_label(self) -> str:
|
||||||
|
if self._phase == "startup":
|
||||||
|
return "启动阶段"
|
||||||
|
return f"轮次 {self._turn_id or 'unknown'}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalized_result(response: ToolSimulationResponse) -> dict[str, Any]:
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
status = "ok" if response.outcome == "success" else "error"
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return {**payload, "status": status}
|
||||||
|
return {"status": status, "data": payload}
|
||||||
|
|
||||||
|
|
||||||
class MockToolExecutor(ToolExecutor):
|
class MockToolExecutor(ToolExecutor):
|
||||||
"""Resolve the active turn's configured mocks and reject everything else."""
|
"""Execute external runtime tools exclusively through one test resolver."""
|
||||||
|
|
||||||
def __init__(self, store: DynamicVariableStore):
|
def __init__(
|
||||||
super().__init__(store)
|
|
||||||
self._mocks_by_tool_id: dict[str, ToolCallExpectedBehavior] = {}
|
|
||||||
self._mocks_by_function: dict[str, ToolCallExpectedBehavior] = {}
|
|
||||||
self.unmocked_calls: list[str] = []
|
|
||||||
|
|
||||||
def set_turn_behaviors(
|
|
||||||
self,
|
self,
|
||||||
behaviors: list[ToolCallExpectedBehavior],
|
store: DynamicVariableStore,
|
||||||
) -> None:
|
resolver: ToolSimulationResolver,
|
||||||
self._mocks_by_tool_id = {item.tool_id: item for item in behaviors}
|
):
|
||||||
self._mocks_by_function = {item.function_name: item for item in behaviors}
|
super().__init__(store)
|
||||||
self.unmocked_calls = []
|
self._resolver = resolver
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
@@ -37,26 +153,10 @@ class MockToolExecutor(ToolExecutor):
|
|||||||
binding: RuntimeToolBinding | None = None,
|
binding: RuntimeToolBinding | None = None,
|
||||||
result_assignments: dict[str, str] | None = None,
|
result_assignments: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
behavior = self._mocks_by_tool_id.get(tool.id) or self._mocks_by_function.get(
|
result = await self._resolver.resolve(
|
||||||
tool.function_name
|
tool_id=tool.id,
|
||||||
|
function_name=tool.function_name,
|
||||||
)
|
)
|
||||||
if behavior is None:
|
|
||||||
self.unmocked_calls.append(tool.function_name)
|
|
||||||
raise ToolExecutionError(
|
|
||||||
f"UNMOCKED_TOOL_CALL: 工具 {tool.function_name} 未配置 Mock 返回值"
|
|
||||||
)
|
|
||||||
|
|
||||||
delay_seconds = behavior.mock_response.delay_ms / 1000
|
|
||||||
if delay_seconds:
|
|
||||||
await asyncio.sleep(delay_seconds)
|
|
||||||
|
|
||||||
payload = json.loads(behavior.mock_response.body)
|
|
||||||
status = "ok" if behavior.mock_response.outcome == "success" else "error"
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
result: dict[str, Any] = dict(payload)
|
|
||||||
result["status"] = status
|
|
||||||
else:
|
|
||||||
result = {"status": status, "data": payload}
|
|
||||||
if result.get("status") != "ok":
|
if result.get("status") != "ok":
|
||||||
return {**result, "updated_variables": []}
|
return {**result, "updated_variables": []}
|
||||||
return self._apply_result_assignments(
|
return self._apply_result_assignments(
|
||||||
@@ -71,13 +171,16 @@ class MockToolExecutor(ToolExecutor):
|
|||||||
|
|
||||||
|
|
||||||
class TextClientToolPort:
|
class TextClientToolPort:
|
||||||
"""Automatically acknowledge built-in message stages in text tests."""
|
"""Return configured client-tool results without a browser transport."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, resolver: ToolSimulationResolver) -> None:
|
||||||
|
self._resolver = resolver
|
||||||
self.auto_confirmed_messages: list[dict[str, Any]] = []
|
self.auto_confirmed_messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def supports(self, function_name: str) -> bool:
|
def supports(self, _function_name: str) -> bool:
|
||||||
return function_name == "show_message"
|
# Test runs advertise the virtual client, then fail strictly in call().
|
||||||
|
# Returning False would silently skip an unconfigured client tool.
|
||||||
|
return True
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
self,
|
self,
|
||||||
@@ -85,12 +188,31 @@ class TextClientToolPort:
|
|||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
**_kwargs: Any,
|
**_kwargs: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if function_name == "show_message":
|
resolved = await self._resolver.resolve(
|
||||||
self.auto_confirmed_messages.append(dict(arguments))
|
tool_id=None,
|
||||||
return {
|
function_name=function_name,
|
||||||
"status": "ok",
|
|
||||||
"data": {"action": "confirmed", "arguments": arguments},
|
|
||||||
}
|
|
||||||
raise ToolExecutionError(
|
|
||||||
f"UNMOCKED_CLIENT_TOOL: 客户端工具 {function_name} 不能脱离 Mock 执行"
|
|
||||||
)
|
)
|
||||||
|
status = str(resolved.get("status") or "error")
|
||||||
|
payload = (
|
||||||
|
resolved.get("data")
|
||||||
|
if set(resolved).issubset({"status", "data"}) and "data" in resolved
|
||||||
|
else {key: value for key, value in resolved.items() if key != "status"}
|
||||||
|
)
|
||||||
|
# ClientToolPort mirrors the browser transport envelope. The JSON
|
||||||
|
# configured in the editor remains the client handler's root object.
|
||||||
|
result: dict[str, Any] = {"status": status, "data": payload}
|
||||||
|
if (
|
||||||
|
status != "ok"
|
||||||
|
and isinstance(payload, dict)
|
||||||
|
and payload.get("message") is not None
|
||||||
|
):
|
||||||
|
result["message"] = str(payload["message"])
|
||||||
|
data = result.get("data")
|
||||||
|
action = data.get("action") if isinstance(data, dict) else None
|
||||||
|
if (
|
||||||
|
function_name == "show_message"
|
||||||
|
and result.get("status") == "ok"
|
||||||
|
and action == "confirmed"
|
||||||
|
):
|
||||||
|
self.auto_confirmed_messages.append(dict(arguments))
|
||||||
|
return result
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
|
|||||||
ErrorFrame,
|
ErrorFrame,
|
||||||
FunctionCallInProgressFrame,
|
FunctionCallInProgressFrame,
|
||||||
FunctionCallResultFrame,
|
FunctionCallResultFrame,
|
||||||
LLMContextFrame,
|
|
||||||
LLMFullResponseEndFrame,
|
LLMFullResponseEndFrame,
|
||||||
LLMFullResponseStartFrame,
|
LLMFullResponseStartFrame,
|
||||||
LLMMessagesAppendFrame,
|
LLMMessagesAppendFrame,
|
||||||
@@ -54,7 +53,13 @@ from services.pipecat.processors import (
|
|||||||
from services.pipecat.service_factory import config_with_resource
|
from services.pipecat.service_factory import config_with_resource
|
||||||
from services.pipecat.workflow_services import build_workflow_llm_switcher
|
from services.pipecat.workflow_services import build_workflow_llm_switcher
|
||||||
from services.test_runs.errors import TestExecutionError
|
from services.test_runs.errors import TestExecutionError
|
||||||
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
|
from services.test_runs.mock_tools import (
|
||||||
|
TOOL_SIMULATION_ERROR_CODES,
|
||||||
|
MockToolExecutor,
|
||||||
|
TextClientToolPort,
|
||||||
|
ToolSimulationError,
|
||||||
|
ToolSimulationResolver,
|
||||||
|
)
|
||||||
from services.vision import build_image_user_message
|
from services.vision import build_image_user_message
|
||||||
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
|
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
|
||||||
|
|
||||||
@@ -197,18 +202,21 @@ class _TextCaptureProcessor(FrameProcessor):
|
|||||||
"""Release every wait even when Pipecat reports the error upstream."""
|
"""Release every wait even when Pipecat reports the error upstream."""
|
||||||
|
|
||||||
message = frame.error or "Pipeline 执行失败"
|
message = frame.error or "Pipeline 执行失败"
|
||||||
unmocked_tool = "UNMOCKED_TOOL_CALL" in message
|
simulation_code = next(
|
||||||
|
(code for code in TOOL_SIMULATION_ERROR_CODES if code in message),
|
||||||
|
None,
|
||||||
|
)
|
||||||
self.error = TestExecutionError(
|
self.error = TestExecutionError(
|
||||||
code="UNMOCKED_TOOL_CALL" if unmocked_tool else "PIPELINE_ERROR",
|
code=simulation_code or "PIPELINE_ERROR",
|
||||||
message=message,
|
message=message,
|
||||||
stage=(
|
stage=(
|
||||||
"tool"
|
"tool"
|
||||||
if unmocked_tool
|
if simulation_code
|
||||||
else "model"
|
else "model"
|
||||||
if frame.processor
|
if frame.processor
|
||||||
else "pipeline"
|
else "pipeline"
|
||||||
),
|
),
|
||||||
retryable=not unmocked_tool,
|
retryable=simulation_code is None,
|
||||||
)
|
)
|
||||||
self.window.pending_tools.clear()
|
self.window.pending_tools.clear()
|
||||||
self.window.touch()
|
self.window.touch()
|
||||||
@@ -573,14 +581,12 @@ class TextPipelineRunner:
|
|||||||
async def on_pipeline_error(_worker, frame):
|
async def on_pipeline_error(_worker, frame):
|
||||||
capture.fail(frame)
|
capture.fail(frame)
|
||||||
|
|
||||||
executor_holder: dict[str, MockToolExecutor] = {}
|
simulation_resolver = ToolSimulationResolver(definition.tool_simulations)
|
||||||
text_client_tools = TextClientToolPort()
|
text_client_tools = TextClientToolPort(simulation_resolver)
|
||||||
workflow_vision_scope: dict[str, Any] = {"enabled": False}
|
workflow_vision_scope: dict[str, Any] = {"enabled": False}
|
||||||
|
|
||||||
def create_test_executor(store) -> MockToolExecutor:
|
def create_test_executor(store) -> MockToolExecutor:
|
||||||
executor = MockToolExecutor(store)
|
return MockToolExecutor(store, simulation_resolver)
|
||||||
executor_holder["executor"] = executor
|
|
||||||
return executor
|
|
||||||
|
|
||||||
async def queue_call_end(reason: str) -> None:
|
async def queue_call_end(reason: str) -> None:
|
||||||
logger.debug(f"文本测试收到会话结束请求: {reason}")
|
logger.debug(f"文本测试收到会话结束请求: {reason}")
|
||||||
@@ -738,8 +744,6 @@ class TextPipelineRunner:
|
|||||||
tool_executor_factory=create_test_executor,
|
tool_executor_factory=create_test_executor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
executor = executor_holder["executor"]
|
|
||||||
|
|
||||||
# A transportless text run cannot synthesize audio, but the configured
|
# A transportless text run cannot synthesize audio, but the configured
|
||||||
# greeting has already completed before scripted inputs begin. Mirror
|
# greeting has already completed before scripted inputs begin. Mirror
|
||||||
# production's provider-safe context fact so the model does not repeat it.
|
# production's provider-safe context fact so the model does not repeat it.
|
||||||
@@ -772,25 +776,11 @@ class TextPipelineRunner:
|
|||||||
# response is therefore the equivalent of playback ending.
|
# response is therefore the equivalent of playback ending.
|
||||||
await call_end.finish()
|
await call_end.finish()
|
||||||
|
|
||||||
all_tool_behaviors = [
|
|
||||||
behavior
|
|
||||||
for turn in definition.turns
|
|
||||||
for behavior in turn.behaviors
|
|
||||||
if behavior.type == "tool_call"
|
|
||||||
]
|
|
||||||
executor.set_turn_behaviors(all_tool_behaviors)
|
|
||||||
|
|
||||||
runner_task: asyncio.Task[None] | None = None
|
runner_task: asyncio.Task[None] | None = None
|
||||||
try:
|
try:
|
||||||
startup_runtime_record_marker = len(capture.runtime_records)
|
startup_runtime_record_marker = len(capture.runtime_records)
|
||||||
|
simulation_resolver.enter_startup()
|
||||||
await brain.run_preflight()
|
await brain.run_preflight()
|
||||||
if executor.unmocked_calls:
|
|
||||||
raise TestExecutionError(
|
|
||||||
code="UNMOCKED_TOOL_CALL",
|
|
||||||
message="启动阶段调用了未配置 Mock 的工具: "
|
|
||||||
+ "、".join(executor.unmocked_calls),
|
|
||||||
stage="tool",
|
|
||||||
)
|
|
||||||
|
|
||||||
await runner.add_workers(worker)
|
await runner.add_workers(worker)
|
||||||
runner_task = asyncio.create_task(
|
runner_task = asyncio.create_task(
|
||||||
@@ -803,12 +793,14 @@ class TextPipelineRunner:
|
|||||||
marker = capture.window.activity_count
|
marker = capture.window.activity_count
|
||||||
await brain.on_connected(greeting_pending=False)
|
await brain.on_connected(greeting_pending=False)
|
||||||
await brain.on_client_ready()
|
await brain.on_client_ready()
|
||||||
|
simulation_resolver.raise_if_failed()
|
||||||
await _wait_for_startup(
|
await _wait_for_startup(
|
||||||
worker,
|
worker,
|
||||||
capture,
|
capture,
|
||||||
runner_task,
|
runner_task,
|
||||||
marker,
|
marker,
|
||||||
)
|
)
|
||||||
|
simulation_resolver.raise_if_failed()
|
||||||
startup_greeting = _resolve_startup_greeting(
|
startup_greeting = _resolve_startup_greeting(
|
||||||
greeting,
|
greeting,
|
||||||
capture.opening_speech_outputs,
|
capture.opening_speech_outputs,
|
||||||
@@ -844,7 +836,7 @@ class TextPipelineRunner:
|
|||||||
worker,
|
worker,
|
||||||
runner_task,
|
runner_task,
|
||||||
capture,
|
capture,
|
||||||
executor,
|
simulation_resolver,
|
||||||
turn,
|
turn,
|
||||||
index,
|
index,
|
||||||
image_assets=image_assets,
|
image_assets=image_assets,
|
||||||
@@ -873,9 +865,24 @@ class TextPipelineRunner:
|
|||||||
)
|
)
|
||||||
except TestExecutionError:
|
except TestExecutionError:
|
||||||
raise
|
raise
|
||||||
|
except ToolSimulationError as exc:
|
||||||
|
raise TestExecutionError(
|
||||||
|
code=exc.code,
|
||||||
|
message=str(exc),
|
||||||
|
stage="tool",
|
||||||
|
retryable=False,
|
||||||
|
) from exc
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if simulation_resolver.failure is not None:
|
||||||
|
error = simulation_resolver.failure
|
||||||
|
raise TestExecutionError(
|
||||||
|
code=error.code,
|
||||||
|
message=str(error),
|
||||||
|
stage="tool",
|
||||||
|
retryable=False,
|
||||||
|
) from exc
|
||||||
raise TestExecutionError(
|
raise TestExecutionError(
|
||||||
code="PIPELINE_EXECUTION_FAILED",
|
code="PIPELINE_EXECUTION_FAILED",
|
||||||
message=str(exc) or type(exc).__name__,
|
message=str(exc) or type(exc).__name__,
|
||||||
@@ -894,17 +901,14 @@ class TextPipelineRunner:
|
|||||||
worker: PipelineWorker,
|
worker: PipelineWorker,
|
||||||
runner_task: asyncio.Task[None],
|
runner_task: asyncio.Task[None],
|
||||||
capture: _TextCaptureProcessor,
|
capture: _TextCaptureProcessor,
|
||||||
executor: MockToolExecutor,
|
simulation_resolver: ToolSimulationResolver,
|
||||||
turn: FixedInputTurn,
|
turn: FixedInputTurn,
|
||||||
index: int,
|
index: int,
|
||||||
*,
|
*,
|
||||||
image_assets: dict[str, bytes],
|
image_assets: dict[str, bytes],
|
||||||
workflow_vision_scope: dict[str, Any],
|
workflow_vision_scope: dict[str, Any],
|
||||||
) -> RawTurnResult:
|
) -> RawTurnResult:
|
||||||
tool_behaviors = [
|
simulation_resolver.enter_turn(turn.id)
|
||||||
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
|
|
||||||
]
|
|
||||||
executor.set_turn_behaviors(tool_behaviors)
|
|
||||||
activity_marker = capture.window.activity_count
|
activity_marker = capture.window.activity_count
|
||||||
output_marker = len(capture.outputs)
|
output_marker = len(capture.outputs)
|
||||||
tool_marker = len(capture.tool_calls)
|
tool_marker = len(capture.tool_calls)
|
||||||
@@ -942,13 +946,7 @@ class TextPipelineRunner:
|
|||||||
LLMMessagesAppendFrame(messages=[user_message], run_llm=True)
|
LLMMessagesAppendFrame(messages=[user_message], run_llm=True)
|
||||||
)
|
)
|
||||||
await _wait_for_quiescence(capture, runner_task, activity_marker)
|
await _wait_for_quiescence(capture, runner_task, activity_marker)
|
||||||
if executor.unmocked_calls:
|
simulation_resolver.raise_if_failed()
|
||||||
raise TestExecutionError(
|
|
||||||
code="UNMOCKED_TOOL_CALL",
|
|
||||||
message="本轮调用了未配置 Mock 的工具: "
|
|
||||||
+ "、".join(executor.unmocked_calls),
|
|
||||||
stage="tool",
|
|
||||||
)
|
|
||||||
return RawTurnResult(
|
return RawTurnResult(
|
||||||
id=turn.id,
|
id=turn.id,
|
||||||
index=index,
|
index=index,
|
||||||
|
|||||||
+223
-2
@@ -77,7 +77,7 @@ class ToolParamAssertion(CamelModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ToolMockResponse(CamelModel):
|
class ToolSimulationResponse(CamelModel):
|
||||||
outcome: Literal["success", "error"] = "success"
|
outcome: Literal["success", "error"] = "success"
|
||||||
body: str = Field(default='{"status":"ok"}', max_length=100_000)
|
body: str = Field(default='{"status":"ok"}', max_length=100_000)
|
||||||
delay_ms: int = Field(default=0, ge=0, le=60_000)
|
delay_ms: int = Field(default=0, ge=0, le=60_000)
|
||||||
@@ -93,6 +93,63 @@ class ToolMockResponse(CamelModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AnyToolSimulationScope(CamelModel):
|
||||||
|
phase: Literal["any"] = "any"
|
||||||
|
|
||||||
|
|
||||||
|
class StartupToolSimulationScope(CamelModel):
|
||||||
|
phase: Literal["startup"] = "startup"
|
||||||
|
|
||||||
|
|
||||||
|
class TurnToolSimulationScope(CamelModel):
|
||||||
|
phase: Literal["turn"] = "turn"
|
||||||
|
turn_id: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
ToolSimulationScope = Annotated[
|
||||||
|
Union[
|
||||||
|
AnyToolSimulationScope,
|
||||||
|
StartupToolSimulationScope,
|
||||||
|
TurnToolSimulationScope,
|
||||||
|
],
|
||||||
|
Field(discriminator="phase"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationStep(ToolSimulationResponse):
|
||||||
|
id: str = Field(min_length=1, max_length=128)
|
||||||
|
scope: ToolSimulationScope = Field(default_factory=AnyToolSimulationScope)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationBase(CamelModel):
|
||||||
|
tool_id: str = Field(min_length=1, max_length=40)
|
||||||
|
function_name: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
@field_validator("tool_id", "function_name")
|
||||||
|
@classmethod
|
||||||
|
def strip_tool_identity(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("必须选择有效的工具")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class FixedToolSimulation(ToolSimulationBase):
|
||||||
|
mode: Literal["fixed"] = "fixed"
|
||||||
|
response: ToolSimulationResponse = Field(default_factory=ToolSimulationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceToolSimulation(ToolSimulationBase):
|
||||||
|
mode: Literal["sequence"] = "sequence"
|
||||||
|
steps: list[ToolSimulationStep] = Field(min_length=1, max_length=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
ToolSimulation = Annotated[
|
||||||
|
Union[FixedToolSimulation, SequenceToolSimulation],
|
||||||
|
Field(discriminator="mode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ReplyExpectedBehavior(CamelModel):
|
class ReplyExpectedBehavior(CamelModel):
|
||||||
id: str = Field(min_length=1, max_length=128)
|
id: str = Field(min_length=1, max_length=128)
|
||||||
type: Literal["reply"] = "reply"
|
type: Literal["reply"] = "reply"
|
||||||
@@ -122,7 +179,6 @@ class ToolCallExpectedBehavior(CamelModel):
|
|||||||
min_calls: int = Field(default=1, ge=0, le=100)
|
min_calls: int = Field(default=1, ge=0, le=100)
|
||||||
max_calls: int | None = Field(default=None, ge=0, le=100)
|
max_calls: int | None = Field(default=None, ge=0, le=100)
|
||||||
param_assertions: list[ToolParamAssertion] = Field(default_factory=list, max_length=50)
|
param_assertions: list[ToolParamAssertion] = Field(default_factory=list, max_length=50)
|
||||||
mock_response: ToolMockResponse = Field(default_factory=ToolMockResponse)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_call_expectation(self):
|
def validate_call_expectation(self):
|
||||||
@@ -191,14 +247,176 @@ class OverallCriterion(CamelModel):
|
|||||||
class TestCaseDefinition(CamelModel):
|
class TestCaseDefinition(CamelModel):
|
||||||
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
||||||
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
||||||
|
tool_simulations: list[ToolSimulation] = Field(default_factory=list, max_length=100)
|
||||||
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def migrate_legacy_tool_mocks(cls, raw):
|
||||||
|
"""Normalize the former per-assertion mockResponse contract on read."""
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return raw
|
||||||
|
if "tool_simulations" in raw or "toolSimulations" in raw:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
for turn_index, turn in enumerate(raw.get("turns") or []):
|
||||||
|
if not isinstance(turn, dict):
|
||||||
|
continue
|
||||||
|
turn_id = str(turn.get("id") or "").strip()
|
||||||
|
for behavior_index, behavior in enumerate(turn.get("behaviors") or []):
|
||||||
|
if not isinstance(behavior, dict):
|
||||||
|
continue
|
||||||
|
if behavior.get("type") != "tool_call":
|
||||||
|
continue
|
||||||
|
if behavior.get("expectation") != "called":
|
||||||
|
continue
|
||||||
|
mock = behavior.get("mock_response") or behavior.get("mockResponse")
|
||||||
|
if not isinstance(mock, dict):
|
||||||
|
continue
|
||||||
|
tool_id = str(
|
||||||
|
behavior.get("tool_id") or behavior.get("toolId") or ""
|
||||||
|
).strip()
|
||||||
|
function_name = str(
|
||||||
|
behavior.get("function_name")
|
||||||
|
or behavior.get("functionName")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not tool_id or not function_name:
|
||||||
|
continue
|
||||||
|
response = {
|
||||||
|
"outcome": str(mock.get("outcome") or "success"),
|
||||||
|
"body": str(mock.get("body") or '{"status":"ok"}'),
|
||||||
|
"delayMs": int(
|
||||||
|
mock.get("delay_ms")
|
||||||
|
if mock.get("delay_ms") is not None
|
||||||
|
else mock.get("delayMs") or 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
groups.setdefault((tool_id, function_name), []).append(
|
||||||
|
{
|
||||||
|
"response": response,
|
||||||
|
"turnId": turn_id,
|
||||||
|
"turnIndex": turn_index,
|
||||||
|
"behaviorIndex": behavior_index,
|
||||||
|
"minCalls": max(
|
||||||
|
1,
|
||||||
|
int(
|
||||||
|
behavior.get("min_calls")
|
||||||
|
if behavior.get("min_calls") is not None
|
||||||
|
else behavior.get("minCalls") or 1
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
migrated: list[dict[str, Any]] = []
|
||||||
|
for (tool_id, function_name), entries in groups.items():
|
||||||
|
response_signatures = {
|
||||||
|
json.dumps(entry["response"], ensure_ascii=False, sort_keys=True)
|
||||||
|
for entry in entries
|
||||||
|
}
|
||||||
|
if len(response_signatures) == 1:
|
||||||
|
migrated.append(
|
||||||
|
{
|
||||||
|
"toolId": tool_id,
|
||||||
|
"functionName": function_name,
|
||||||
|
"mode": "fixed",
|
||||||
|
"response": entries[0]["response"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
for repeat_index in range(entry["minCalls"]):
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"id": (
|
||||||
|
f"legacy_step_{entry['turnIndex'] + 1}_"
|
||||||
|
f"{entry['behaviorIndex'] + 1}_{repeat_index + 1}"
|
||||||
|
),
|
||||||
|
"scope": {
|
||||||
|
"phase": "turn",
|
||||||
|
"turnId": entry["turnId"],
|
||||||
|
},
|
||||||
|
**entry["response"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
migrated.append(
|
||||||
|
{
|
||||||
|
"toolId": tool_id,
|
||||||
|
"functionName": function_name,
|
||||||
|
"mode": "sequence",
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized = dict(raw)
|
||||||
|
normalized["toolSimulations"] = migrated
|
||||||
|
return normalized
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_has_expectation(self):
|
def validate_has_expectation(self):
|
||||||
if not any(turn.behaviors for turn in self.turns) and not self.overall_criteria:
|
if not any(turn.behaviors for turn in self.turns) and not self.overall_criteria:
|
||||||
raise ValueError("至少配置一项有效的预期行为或整体评估标准")
|
raise ValueError("至少配置一项有效的预期行为或整体评估标准")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_tool_simulations(self):
|
||||||
|
tool_ids: set[str] = set()
|
||||||
|
step_ids: set[str] = set()
|
||||||
|
turn_ids = {turn.id for turn in self.turns}
|
||||||
|
simulations_by_tool: dict[str, ToolSimulation] = {}
|
||||||
|
|
||||||
|
for simulation in self.tool_simulations:
|
||||||
|
if simulation.tool_id in tool_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"工具 {simulation.function_name} 只能配置一条工具模拟"
|
||||||
|
)
|
||||||
|
tool_ids.add(simulation.tool_id)
|
||||||
|
simulations_by_tool[simulation.tool_id] = simulation
|
||||||
|
if simulation.mode != "sequence":
|
||||||
|
continue
|
||||||
|
for step in simulation.steps:
|
||||||
|
if step.id in step_ids:
|
||||||
|
raise ValueError(f"工具模拟步骤 ID 重复: {step.id}")
|
||||||
|
step_ids.add(step.id)
|
||||||
|
if step.scope.phase == "turn" and step.scope.turn_id not in turn_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"工具模拟步骤引用了不存在的轮次: {step.scope.turn_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for turn in self.turns:
|
||||||
|
for behavior in turn.behaviors:
|
||||||
|
if behavior.type != "tool_call" or behavior.expectation != "called":
|
||||||
|
continue
|
||||||
|
simulation = simulations_by_tool.get(behavior.tool_id)
|
||||||
|
if simulation is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"工具 {behavior.function_name} 配置了应调用断言,但缺少工具模拟"
|
||||||
|
)
|
||||||
|
if simulation.function_name != behavior.function_name:
|
||||||
|
raise ValueError(
|
||||||
|
f"工具 {behavior.function_name} 的断言与模拟不匹配"
|
||||||
|
)
|
||||||
|
if simulation.mode == "fixed":
|
||||||
|
continue
|
||||||
|
covers_turn = any(
|
||||||
|
step.scope.phase == "any"
|
||||||
|
or (
|
||||||
|
step.scope.phase == "turn"
|
||||||
|
and step.scope.turn_id == turn.id
|
||||||
|
)
|
||||||
|
for step in simulation.steps
|
||||||
|
)
|
||||||
|
if not covers_turn:
|
||||||
|
raise ValueError(
|
||||||
|
f"工具 {behavior.function_name} 缺少覆盖本轮的模拟步骤"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_tool_context_pairs(self):
|
def validate_tool_context_pairs(self):
|
||||||
pending: list[tuple[str | None, str]] = []
|
pending: list[tuple[str | None, str]] = []
|
||||||
@@ -275,6 +493,7 @@ class TestCaseWrite(CamelModel):
|
|||||||
input_mode: TestCaseInputMode = "fixed_script_text"
|
input_mode: TestCaseInputMode = "fixed_script_text"
|
||||||
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
||||||
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
||||||
|
tool_simulations: list[ToolSimulation] = Field(default_factory=list, max_length=100)
|
||||||
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@@ -292,6 +511,7 @@ class TestCaseWrite(CamelModel):
|
|||||||
TestCaseDefinition(
|
TestCaseDefinition(
|
||||||
context_turns=self.context_turns,
|
context_turns=self.context_turns,
|
||||||
turns=self.turns,
|
turns=self.turns,
|
||||||
|
tool_simulations=self.tool_simulations,
|
||||||
overall_criteria=self.overall_criteria,
|
overall_criteria=self.overall_criteria,
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
@@ -300,6 +520,7 @@ class TestCaseWrite(CamelModel):
|
|||||||
return TestCaseDefinition(
|
return TestCaseDefinition(
|
||||||
context_turns=self.context_turns,
|
context_turns=self.context_turns,
|
||||||
turns=self.turns,
|
turns=self.turns,
|
||||||
|
tool_simulations=self.tool_simulations,
|
||||||
overall_criteria=self.overall_criteria,
|
overall_criteria=self.overall_criteria,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from pipecat.processors.aggregators.llm_context import LLMContext
|
|||||||
from services.brains import BrainRuntime, build_brain
|
from services.brains import BrainRuntime, build_brain
|
||||||
from services.brains.base import GREETING_CONTEXT_MARKER
|
from services.brains.base import GREETING_CONTEXT_MARKER
|
||||||
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config
|
||||||
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
|
from services.test_runs.mock_tools import (
|
||||||
|
MockToolExecutor,
|
||||||
|
TextClientToolPort,
|
||||||
|
ToolSimulationResolver,
|
||||||
|
)
|
||||||
from services.test_runs.text_runner import (
|
from services.test_runs.text_runner import (
|
||||||
_TextCaptureProcessor,
|
_TextCaptureProcessor,
|
||||||
_opening_speech_from_message,
|
_opening_speech_from_message,
|
||||||
@@ -16,7 +20,7 @@ from services.test_runs.text_runner import (
|
|||||||
_wait_for_startup,
|
_wait_for_startup,
|
||||||
_wait_for_quiescence,
|
_wait_for_quiescence,
|
||||||
)
|
)
|
||||||
from test_schemas import BatchRunCreate, BatchStartupResult
|
from test_schemas import BatchRunCreate, BatchStartupResult, FixedToolSimulation
|
||||||
|
|
||||||
|
|
||||||
class FakeLLM:
|
class FakeLLM:
|
||||||
@@ -154,8 +158,24 @@ class DebugAutoTestContractTests(unittest.TestCase):
|
|||||||
"message_started",
|
"message_started",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_text_client_port_records_automatic_message_confirmation(self):
|
def test_text_client_port_records_configured_message_confirmation(self):
|
||||||
port = TextClientToolPort()
|
resolver = ToolSimulationResolver(
|
||||||
|
[
|
||||||
|
FixedToolSimulation.model_validate(
|
||||||
|
{
|
||||||
|
"toolId": "tool_message",
|
||||||
|
"functionName": "show_message",
|
||||||
|
"mode": "fixed",
|
||||||
|
"response": {
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"action":"confirmed"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
port = TextClientToolPort(resolver)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
port.call(
|
port.call(
|
||||||
@@ -230,12 +250,9 @@ class DebugAutoTestRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
brain = build_brain(cfg)
|
brain = build_brain(cfg)
|
||||||
llm = FakeLLM()
|
llm = FakeLLM()
|
||||||
call_end = FakeCallEnd()
|
call_end = FakeCallEnd()
|
||||||
executor_holder = {}
|
|
||||||
|
|
||||||
def create_executor(store):
|
def create_executor(store):
|
||||||
executor = MockToolExecutor(store)
|
return MockToolExecutor(store, ToolSimulationResolver([]))
|
||||||
executor_holder["executor"] = executor
|
|
||||||
return executor
|
|
||||||
|
|
||||||
await brain.setup(
|
await brain.setup(
|
||||||
cfg,
|
cfg,
|
||||||
@@ -256,7 +273,6 @@ class DebugAutoTestRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(call_end.ending)
|
self.assertTrue(call_end.ending)
|
||||||
self.assertEqual(call_end.reason, "测试完成")
|
self.assertEqual(call_end.reason, "测试完成")
|
||||||
self.assertEqual(params.result["action"], "ending_call")
|
self.assertEqual(params.result["action"], "ending_call")
|
||||||
self.assertEqual(executor_holder["executor"].unmocked_calls, [])
|
|
||||||
|
|
||||||
async def test_text_capture_keeps_runtime_records_for_auto_test_ui(self):
|
async def test_text_capture_keeps_runtime_records_for_auto_test_ui(self):
|
||||||
event = {
|
event = {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from fastapi import HTTPException
|
|||||||
from routes.assistants import _validate_startup_actions
|
from routes.assistants import _validate_startup_actions
|
||||||
from models import RuntimeToolBinding
|
from models import RuntimeToolBinding
|
||||||
from schemas import AssistantUpsert
|
from schemas import AssistantUpsert
|
||||||
from services.config_resolver import _runtime_tool_ids
|
from services.config_resolver import (
|
||||||
|
_implicit_startup_tool_names,
|
||||||
|
_runtime_tool_ids,
|
||||||
|
_tools_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def startup_body(
|
def startup_body(
|
||||||
@@ -57,6 +61,25 @@ class FakeSession:
|
|||||||
return self.tool if self.tool is not None and tool_id == self.tool.id else None
|
return self.tool if self.tool is not None and tool_id == self.tool.id else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolQueryResult:
|
||||||
|
def __init__(self, tools):
|
||||||
|
self.tools = tools
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.tools
|
||||||
|
|
||||||
|
|
||||||
|
class FakeToolCatalogSession:
|
||||||
|
def __init__(self, tools):
|
||||||
|
self.tools = tools
|
||||||
|
|
||||||
|
async def execute(self, _statement):
|
||||||
|
return FakeToolQueryResult(self.tools)
|
||||||
|
|
||||||
|
|
||||||
class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
|
class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_runtime_pool_includes_bound_and_startup_only_tools(self):
|
def test_runtime_pool_includes_bound_and_startup_only_tools(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -75,6 +98,51 @@ class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
["conversation_tool", "shared_tool", "opening_tool"],
|
["conversation_tool", "shared_tool", "opening_tool"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_confirmation_opening_declares_implicit_show_message_tool(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_implicit_startup_tool_names(
|
||||||
|
{
|
||||||
|
"opening_mode": "confirmation",
|
||||||
|
"opening_message": {
|
||||||
|
"title": "重要提示",
|
||||||
|
"message": "请确认已阅读。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
["show_message"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_implicit_startup_tool_names(
|
||||||
|
{
|
||||||
|
"openingMode": "playback",
|
||||||
|
"openingMessage": {"message": "无需确认"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_implicit_show_message_enters_runtime_pool_without_binding(self):
|
||||||
|
tool = SimpleNamespace(
|
||||||
|
id="tool_show_message_default",
|
||||||
|
name="显示确认消息",
|
||||||
|
function_name="show_message",
|
||||||
|
type="client",
|
||||||
|
description="显示确认弹窗",
|
||||||
|
definition={"config": {}},
|
||||||
|
secrets={},
|
||||||
|
mcp_server_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_tools = await _tools_for(
|
||||||
|
FakeToolCatalogSession([tool]),
|
||||||
|
SimpleNamespace(type="prompt"),
|
||||||
|
[],
|
||||||
|
["show_message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([item.id for item in runtime_tools], [tool.id])
|
||||||
|
self.assertEqual(runtime_tools[0].function_name, "show_message")
|
||||||
|
|
||||||
def test_realtime_rejects_startup_actions(self):
|
def test_realtime_rejects_startup_actions(self):
|
||||||
with self.assertRaisesRegex(ValueError, "Realtime"):
|
with self.assertRaisesRegex(ValueError, "Realtime"):
|
||||||
AssistantUpsert(
|
AssistantUpsert(
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from services.message_policy import MESSAGE_CONFIRMATION
|
||||||
|
from services.message_stage import (
|
||||||
|
MessageDisplaySpec,
|
||||||
|
MessageStageRunner,
|
||||||
|
MessageStageSpec,
|
||||||
|
)
|
||||||
|
from services.test_runs.mock_tools import (
|
||||||
|
TextClientToolPort,
|
||||||
|
ToolSimulationError,
|
||||||
|
ToolSimulationResolver,
|
||||||
|
)
|
||||||
|
from test_schemas import TestCaseDefinition
|
||||||
|
|
||||||
|
|
||||||
|
def definition_with_simulation(simulation: dict) -> TestCaseDefinition:
|
||||||
|
return TestCaseDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"id": "turn_1",
|
||||||
|
"userInput": "查询订单",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_1",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 1,
|
||||||
|
"maxCalls": 1,
|
||||||
|
"paramAssertions": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"toolSimulations": [simulation],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationSchemaTests(unittest.TestCase):
|
||||||
|
def test_called_assertion_requires_simulation(self):
|
||||||
|
with self.assertRaisesRegex(ValidationError, "缺少工具模拟"):
|
||||||
|
TestCaseDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"id": "turn_1",
|
||||||
|
"userInput": "查询订单",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_1",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 1,
|
||||||
|
"paramAssertions": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sequence_turn_scope_must_reference_existing_turn(self):
|
||||||
|
with self.assertRaisesRegex(ValidationError, "不存在的轮次"):
|
||||||
|
definition_with_simulation(
|
||||||
|
{
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"mode": "sequence",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step_1",
|
||||||
|
"scope": {"phase": "turn", "turnId": "missing"},
|
||||||
|
"outcome": "success",
|
||||||
|
"body": "{}",
|
||||||
|
"delayMs": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_equal_responses_become_fixed_simulation(self):
|
||||||
|
definition = TestCaseDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"id": "turn_1",
|
||||||
|
"userInput": "第一次查询",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_1",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 1,
|
||||||
|
"paramAssertions": [],
|
||||||
|
"mockResponse": {
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"orderId":"A1"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "turn_2",
|
||||||
|
"userInput": "再次查询",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_2",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 1,
|
||||||
|
"paramAssertions": [],
|
||||||
|
"mockResponse": {
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"orderId":"A1"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(definition.tool_simulations[0].mode, "fixed")
|
||||||
|
dumped = definition.model_dump(mode="json", by_alias=True)
|
||||||
|
self.assertNotIn("mockResponse", dumped["turns"][0]["behaviors"][0])
|
||||||
|
|
||||||
|
def test_legacy_different_responses_become_scoped_sequence(self):
|
||||||
|
definition = TestCaseDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"id": "turn_1",
|
||||||
|
"userInput": "第一次查询",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_1",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 2,
|
||||||
|
"paramAssertions": [],
|
||||||
|
"mockResponse": {
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"orderId":"A1"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "turn_2",
|
||||||
|
"userInput": "再次查询",
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "behavior_2",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"expectation": "called",
|
||||||
|
"minCalls": 1,
|
||||||
|
"paramAssertions": [],
|
||||||
|
"mockResponse": {
|
||||||
|
"outcome": "error",
|
||||||
|
"body": '{"message":"not found"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation = definition.tool_simulations[0]
|
||||||
|
self.assertEqual(simulation.mode, "sequence")
|
||||||
|
self.assertEqual(len(simulation.steps), 3)
|
||||||
|
self.assertEqual(simulation.steps[0].scope.turn_id, "turn_1")
|
||||||
|
self.assertEqual(simulation.steps[2].scope.turn_id, "turn_2")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSimulationResolverTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_client_message_stage_keeps_unmocked_failure_for_runner(self):
|
||||||
|
resolver = ToolSimulationResolver([])
|
||||||
|
stage = MessageStageRunner(TextClientToolPort(resolver))
|
||||||
|
|
||||||
|
result = await stage.run(
|
||||||
|
MessageStageSpec(
|
||||||
|
display=MessageDisplaySpec(title="确认", message="请确认"),
|
||||||
|
completion_policy=MESSAGE_CONFIRMATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result.succeeded)
|
||||||
|
self.assertIn("UNMOCKED_TOOL_CALL", result.error or "")
|
||||||
|
with self.assertRaisesRegex(ToolSimulationError, "UNMOCKED_TOOL_CALL"):
|
||||||
|
resolver.raise_if_failed()
|
||||||
|
|
||||||
|
async def test_fixed_response_can_be_reused(self):
|
||||||
|
definition = definition_with_simulation(
|
||||||
|
{
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"mode": "fixed",
|
||||||
|
"response": {
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"orderId":"A1"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolver = ToolSimulationResolver(definition.tool_simulations)
|
||||||
|
resolver.enter_turn("turn_1")
|
||||||
|
|
||||||
|
first = await resolver.resolve(
|
||||||
|
tool_id="tool_order", function_name="query_order"
|
||||||
|
)
|
||||||
|
second = await resolver.resolve(
|
||||||
|
tool_id="tool_order", function_name="query_order"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first, {"orderId": "A1", "status": "ok"})
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
|
||||||
|
async def test_sequence_consumes_client_choices_then_exhausts(self):
|
||||||
|
definition = definition_with_simulation(
|
||||||
|
{
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"mode": "sequence",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step_1",
|
||||||
|
"scope": {"phase": "turn", "turnId": "turn_1"},
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"action":"selected","value":"basic"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step_2",
|
||||||
|
"scope": {"phase": "any"},
|
||||||
|
"outcome": "success",
|
||||||
|
"body": '{"action":"dismissed"}',
|
||||||
|
"delayMs": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolver = ToolSimulationResolver(definition.tool_simulations)
|
||||||
|
resolver.enter_turn("turn_1")
|
||||||
|
port = TextClientToolPort(resolver)
|
||||||
|
|
||||||
|
selected = await port.call("query_order", {})
|
||||||
|
dismissed = await port.call("query_order", {})
|
||||||
|
|
||||||
|
self.assertEqual(selected["data"]["value"], "basic")
|
||||||
|
self.assertEqual(dismissed["data"]["action"], "dismissed")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ToolSimulationError, "TOOL_SIMULATION_EXHAUSTED"
|
||||||
|
):
|
||||||
|
await port.call("query_order", {})
|
||||||
|
|
||||||
|
async def test_missing_and_scope_mismatch_are_distinct(self):
|
||||||
|
definition = definition_with_simulation(
|
||||||
|
{
|
||||||
|
"toolId": "tool_order",
|
||||||
|
"functionName": "query_order",
|
||||||
|
"mode": "sequence",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step_1",
|
||||||
|
"scope": {"phase": "turn", "turnId": "turn_1"},
|
||||||
|
"outcome": "success",
|
||||||
|
"body": "{}",
|
||||||
|
"delayMs": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolver = ToolSimulationResolver(definition.tool_simulations)
|
||||||
|
resolver.enter_startup()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ToolSimulationError, "TOOL_SIMULATION_SCOPE_MISMATCH"
|
||||||
|
):
|
||||||
|
await resolver.resolve(
|
||||||
|
tool_id="tool_order", function_name="query_order"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ToolSimulationError, "TOOL_SIMULATION_SCOPE_MISMATCH"
|
||||||
|
):
|
||||||
|
resolver.raise_if_failed()
|
||||||
|
with self.assertRaisesRegex(ToolSimulationError, "UNMOCKED_TOOL_CALL"):
|
||||||
|
await resolver.resolve(tool_id="missing", function_name="missing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -36,11 +36,12 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { SearchInput } from "@/components/ui/search-input";
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
import {
|
||||||
BatchEvaluationResult,
|
testExecutionErrorLabel,
|
||||||
BatchRunCase,
|
type BatchEvaluationResult,
|
||||||
BatchRunSnapshot,
|
type BatchRunCase,
|
||||||
BatchStartupResult,
|
type BatchRunSnapshot,
|
||||||
|
type BatchStartupResult,
|
||||||
} from "@/data/batch-run";
|
} from "@/data/batch-run";
|
||||||
import {
|
import {
|
||||||
getTestCaseValidationMessage,
|
getTestCaseValidationMessage,
|
||||||
@@ -422,7 +423,7 @@ export function AutoTestLivePanel({
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ErrorPanel
|
<ErrorPanel
|
||||||
title={state.runCase.executionError.code}
|
title={testExecutionErrorLabel(state.runCase.executionError.code)}
|
||||||
description={state.runCase.executionError.message}
|
description={state.runCase.executionError.message}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -504,7 +505,7 @@ export function AutoTestLivePanel({
|
|||||||
{state.phase === "error" && state.runCase?.executionError && (
|
{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">
|
<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">
|
<span className="font-medium">
|
||||||
{state.runCase.executionError.code}
|
{testExecutionErrorLabel(state.runCase.executionError.code)}
|
||||||
</span>
|
</span>
|
||||||
<span> · {state.runCase.executionError.message}</span>
|
<span> · {state.runCase.executionError.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { BatchCaseStatusBadge } from "@/components/batch-test/batch-case-status"
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
firstEvaluationFailureReason,
|
firstEvaluationFailureReason,
|
||||||
|
testExecutionErrorLabel,
|
||||||
type BatchEvaluationResult,
|
type BatchEvaluationResult,
|
||||||
type BatchRunCase,
|
type BatchRunCase,
|
||||||
type BatchToolCallRecord,
|
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="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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<code className="text-xs font-medium text-foreground">
|
<code className="text-xs font-medium text-foreground">
|
||||||
{item.executionError.code}
|
{testExecutionErrorLabel(item.executionError.code)}
|
||||||
</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">
|
<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}
|
{item.executionError.stage}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
DEFAULT_INPUT_MODE,
|
DEFAULT_INPUT_MODE,
|
||||||
getTestCaseValidationMessage,
|
getTestCaseValidationMessage,
|
||||||
normalizeOverallCriteria,
|
normalizeOverallCriteria,
|
||||||
|
prepareFixedToolSimulations,
|
||||||
TEST_CASE_INPUT_MODE_LABEL,
|
TEST_CASE_INPUT_MODE_LABEL,
|
||||||
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
TEST_CASE_INPUT_MODE_SHORT_LABEL,
|
||||||
type TestCase,
|
type TestCase,
|
||||||
@@ -420,6 +421,7 @@ function caseToDraft(item: TestCase): CaseEditorDraft {
|
|||||||
inputMode: item.inputMode,
|
inputMode: item.inputMode,
|
||||||
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
contextTurns: item.contextTurns.map((turn) => ({ ...turn })),
|
||||||
turns: cloneTurns(item.turns),
|
turns: cloneTurns(item.turns),
|
||||||
|
toolSimulations: prepareFixedToolSimulations(item.toolSimulations),
|
||||||
overallCriteria: cloneOverallCriteria(item.overallCriteria),
|
overallCriteria: cloneOverallCriteria(item.overallCriteria),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -434,6 +436,7 @@ function createEmptyCaseDraft(): CaseEditorDraft {
|
|||||||
inputMode: DEFAULT_INPUT_MODE,
|
inputMode: DEFAULT_INPUT_MODE,
|
||||||
contextTurns: [],
|
contextTurns: [],
|
||||||
turns: [createEmptyFixedInputTurn()],
|
turns: [createEmptyFixedInputTurn()],
|
||||||
|
toolSimulations: [],
|
||||||
overallCriteria: [],
|
overallCriteria: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -625,6 +628,7 @@ function SuiteDetailView({
|
|||||||
inputMode: draft.inputMode,
|
inputMode: draft.inputMode,
|
||||||
contextTurns: draft.contextTurns,
|
contextTurns: draft.contextTurns,
|
||||||
turns: draft.turns,
|
turns: draft.turns,
|
||||||
|
toolSimulations: draft.toolSimulations,
|
||||||
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
|
overallCriteria: normalizeOverallCriteria(draft.overallCriteria),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
ToolSimulationEditor,
|
||||||
|
type EditorToolOption,
|
||||||
|
} from "@/components/test-cases/tool-simulation-editor";
|
||||||
import {
|
import {
|
||||||
ASSERTION_TYPE_LABEL,
|
ASSERTION_TYPE_LABEL,
|
||||||
KEYWORD_MATCH_MODE_LABEL,
|
KEYWORD_MATCH_MODE_LABEL,
|
||||||
@@ -76,6 +80,7 @@ import {
|
|||||||
type TestImageRef,
|
type TestImageRef,
|
||||||
type TestCaseInputMode,
|
type TestCaseInputMode,
|
||||||
type ToolCallExpectedBehavior,
|
type ToolCallExpectedBehavior,
|
||||||
|
type ToolSimulation,
|
||||||
type ToolParamAssertion,
|
type ToolParamAssertion,
|
||||||
type ToolParamMatchMode,
|
type ToolParamMatchMode,
|
||||||
} from "@/data/test-suites";
|
} from "@/data/test-suites";
|
||||||
@@ -87,6 +92,7 @@ export type CaseEditorDraft = {
|
|||||||
inputMode: TestCaseInputMode;
|
inputMode: TestCaseInputMode;
|
||||||
contextTurns: ContextTurn[];
|
contextTurns: ContextTurn[];
|
||||||
turns: FixedInputTurn[];
|
turns: FixedInputTurn[];
|
||||||
|
toolSimulations: ToolSimulation[];
|
||||||
overallCriteria: OverallCriterion[];
|
overallCriteria: OverallCriterion[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,15 +110,7 @@ function contextPlaceholder(role: ContextRole): string {
|
|||||||
return '{\n "status": "accepted"\n}';
|
return '{\n "status": "accepted"\n}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑器内使用的工具选项(来自 Agent 工具库) */
|
type EditorSectionId = "context" | "main" | "tools" | "overall";
|
||||||
export type EditorToolOption = {
|
|
||||||
id: string;
|
|
||||||
functionName: string;
|
|
||||||
label: string;
|
|
||||||
parameters: { name: string; description: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditorSectionId = "context" | "main" | "overall";
|
|
||||||
|
|
||||||
function editorSections(): {
|
function editorSections(): {
|
||||||
id: EditorSectionId;
|
id: EditorSectionId;
|
||||||
@@ -121,6 +119,7 @@ function editorSections(): {
|
|||||||
return [
|
return [
|
||||||
{ id: "context", label: "对话上下文" },
|
{ id: "context", label: "对话上下文" },
|
||||||
{ id: "main", label: "固定输入" },
|
{ id: "main", label: "固定输入" },
|
||||||
|
{ id: "tools", label: "工具模拟" },
|
||||||
{ id: "overall", label: "整体评估" },
|
{ id: "overall", label: "整体评估" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -156,6 +155,7 @@ function toEditorToolOption(tool: Tool): EditorToolOption {
|
|||||||
id: tool.id,
|
id: tool.id,
|
||||||
functionName: tool.functionName || tool.name,
|
functionName: tool.functionName || tool.name,
|
||||||
label: tool.name || tool.functionName,
|
label: tool.name || tool.functionName,
|
||||||
|
type: tool.type,
|
||||||
parameters: toolParametersFromDefinition(tool),
|
parameters: toolParametersFromDefinition(tool),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,7 @@ export function NextReplyEditorBody({
|
|||||||
const sectionRefs = useRef<Record<EditorSectionId, HTMLElement | null>>({
|
const sectionRefs = useRef<Record<EditorSectionId, HTMLElement | null>>({
|
||||||
context: null,
|
context: null,
|
||||||
main: null,
|
main: null,
|
||||||
|
tools: null,
|
||||||
overall: null,
|
overall: null,
|
||||||
});
|
});
|
||||||
const selectedAnchorRef = useRef<EditorSectionId | null>(null);
|
const selectedAnchorRef = useRef<EditorSectionId | null>(null);
|
||||||
@@ -230,7 +231,12 @@ export function NextReplyEditorBody({
|
|||||||
|
|
||||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||||
const activationLine = containerTop + 24;
|
const activationLine = containerTop + 24;
|
||||||
const sectionIds: EditorSectionId[] = ["context", "main", "overall"];
|
const sectionIds: EditorSectionId[] = [
|
||||||
|
"context",
|
||||||
|
"main",
|
||||||
|
"tools",
|
||||||
|
"overall",
|
||||||
|
];
|
||||||
let nextSection: EditorSectionId = sectionIds[0];
|
let nextSection: EditorSectionId = sectionIds[0];
|
||||||
|
|
||||||
for (const sectionId of sectionIds) {
|
for (const sectionId of sectionIds) {
|
||||||
@@ -394,7 +400,10 @@ export function NextReplyEditorBody({
|
|||||||
|
|
||||||
function removeTurn(turnId: string) {
|
function removeTurn(turnId: string) {
|
||||||
if (draft.turns.length <= 1) return;
|
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) {
|
function handleTurnDragEnd(event: DragEndEvent) {
|
||||||
@@ -681,6 +690,28 @@ export function NextReplyEditorBody({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</section>
|
</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
|
<section
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
sectionRefs.current.overall = element;
|
sectionRefs.current.overall = element;
|
||||||
@@ -1094,7 +1125,7 @@ function SortableFixedInputTurn({
|
|||||||
>
|
>
|
||||||
<Wrench
|
<Wrench
|
||||||
size={15}
|
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="min-w-0">
|
||||||
<span className="block text-sm font-medium text-foreground">
|
<span className="block text-sm font-medium text-foreground">
|
||||||
@@ -1140,7 +1171,7 @@ function ExpectedBehaviorCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"group/card w-full rounded-xl border px-3 py-2.5 text-left transition-colors hover:bg-canvas-soft/60",
|
"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"
|
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",
|
: "border-hairline bg-canvas-soft/30",
|
||||||
validationMessage && "border-destructive/40",
|
validationMessage && "border-destructive/40",
|
||||||
)}
|
)}
|
||||||
@@ -1168,7 +1199,7 @@ function ExpectedBehaviorCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border px-3 py-2.5",
|
"rounded-xl border px-3 py-2.5",
|
||||||
behavior.type === "tool_call"
|
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",
|
: "border-hairline bg-canvas-soft/40",
|
||||||
validationMessage && "border-destructive/40",
|
validationMessage && "border-destructive/40",
|
||||||
)}
|
)}
|
||||||
@@ -1184,7 +1215,7 @@ function ExpectedBehaviorCard({
|
|||||||
{behavior.type === "reply" ? (
|
{behavior.type === "reply" ? (
|
||||||
<MessageSquareText size={14} className="shrink-0 text-muted-foreground" />
|
<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">
|
<span className="text-xs font-medium text-foreground">
|
||||||
{behavior.type === "reply" ? "回复要求" : "工具调用"}
|
{behavior.type === "reply" ? "回复要求" : "工具调用"}
|
||||||
@@ -1257,7 +1288,7 @@ function BehaviorSummary({ behavior }: { behavior: ExpectedBehavior }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
<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" ? "禁止调用 · " : ""}
|
{behavior.expectation === "not_called" ? "禁止调用 · " : ""}
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
@@ -1712,87 +1743,9 @@ function ToolCallBehaviorEditor({
|
|||||||
</div>
|
</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" && (
|
{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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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;
|
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 = {
|
export type BatchEvaluationResult = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -56,13 +56,37 @@ export type ReplyExpectedBehavior = {
|
|||||||
llmCriteria: string;
|
llmCriteria: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolMockResponse = {
|
export type ToolSimulationResponse = {
|
||||||
outcome: "success" | "error";
|
outcome: "success" | "error";
|
||||||
/** JSON 文本,运行时会作为工具返回值注入 pipeline */
|
/** JSON 文本,运行时会作为工具返回值注入 pipeline */
|
||||||
body: string;
|
body: string;
|
||||||
delayMs: number;
|
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 = {
|
export type ToolCallExpectedBehavior = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,8 +98,6 @@ export type ToolCallExpectedBehavior = {
|
|||||||
maxCalls: number | null;
|
maxCalls: number | null;
|
||||||
/** 仅包含用户主动配置的参数;未列出的参数不校验 */
|
/** 仅包含用户主动配置的参数;未列出的参数不校验 */
|
||||||
paramAssertions: ToolParamAssertion[];
|
paramAssertions: ToolParamAssertion[];
|
||||||
/** 调用工具后注入 pipeline 的稳定返回值 */
|
|
||||||
mockResponse: ToolMockResponse;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
|
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
|
||||||
@@ -132,6 +154,8 @@ export type TestCase = {
|
|||||||
contextTurns: ContextTurn[];
|
contextTurns: ContextTurn[];
|
||||||
/** 固定脚本轮次 */
|
/** 固定脚本轮次 */
|
||||||
turns: FixedInputTurn[];
|
turns: FixedInputTurn[];
|
||||||
|
/** 测试用例级工具模拟;与工具调用断言相互独立 */
|
||||||
|
toolSimulations: ToolSimulation[];
|
||||||
/** 整段对话业务目标预期;所有标准均通过才算用例通过 */
|
/** 整段对话业务目标预期;所有标准均通过才算用例通过 */
|
||||||
overallCriteria: OverallCriterion[];
|
overallCriteria: OverallCriterion[];
|
||||||
/** 套件内排序,越小越靠前 */
|
/** 套件内排序,越小越靠前 */
|
||||||
@@ -148,7 +172,11 @@ export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
|
|||||||
export function getTestCaseValidationMessage(
|
export function getTestCaseValidationMessage(
|
||||||
item: Pick<
|
item: Pick<
|
||||||
TestCase,
|
TestCase,
|
||||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
| "inputMode"
|
||||||
|
| "contextTurns"
|
||||||
|
| "turns"
|
||||||
|
| "toolSimulations"
|
||||||
|
| "overallCriteria"
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (item.inputMode !== "fixed_script_text") {
|
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) {
|
for (let index = 0; index < item.overallCriteria.length; index += 1) {
|
||||||
const criterion = item.overallCriteria[index];
|
const criterion = item.overallCriteria[index];
|
||||||
if (!criterion.name.trim()) {
|
if (!criterion.name.trim()) {
|
||||||
@@ -269,7 +340,11 @@ export function getTestCaseValidationMessage(
|
|||||||
export function isTestCaseRunnable(
|
export function isTestCaseRunnable(
|
||||||
item: Pick<
|
item: Pick<
|
||||||
TestCase,
|
TestCase,
|
||||||
"inputMode" | "contextTurns" | "turns" | "overallCriteria"
|
| "inputMode"
|
||||||
|
| "contextTurns"
|
||||||
|
| "turns"
|
||||||
|
| "toolSimulations"
|
||||||
|
| "overallCriteria"
|
||||||
>,
|
>,
|
||||||
): boolean {
|
): boolean {
|
||||||
return getTestCaseValidationMessage(item) === null;
|
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;
|
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(
|
export function getExpectedBehaviorValidationMessage(
|
||||||
behavior: ExpectedBehavior,
|
behavior: ExpectedBehavior,
|
||||||
@@ -353,21 +476,6 @@ export function getExpectedBehaviorValidationMessage(
|
|||||||
(item) => !item.name.trim() || !item.value.trim(),
|
(item) => !item.name.trim() || !item.value.trim(),
|
||||||
);
|
);
|
||||||
if (emptyParam) return "工具参数断言的名称和值不能为空";
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -387,6 +495,29 @@ function nextCriterionId() {
|
|||||||
return newEditorId("criterion");
|
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(
|
export function createOverallCriterion(
|
||||||
name = "新的评估标准",
|
name = "新的评估标准",
|
||||||
): OverallCriterion {
|
): OverallCriterion {
|
||||||
@@ -453,11 +584,6 @@ export function createToolCallBehavior(input?: {
|
|||||||
minCalls: 1,
|
minCalls: 1,
|
||||||
maxCalls: null,
|
maxCalls: null,
|
||||||
paramAssertions: [],
|
paramAssertions: [],
|
||||||
mockResponse: {
|
|
||||||
outcome: "success",
|
|
||||||
body: '{\n "status": "ok"\n}',
|
|
||||||
delayMs: 0,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,11 +608,6 @@ function cloneBehavior(behavior: ExpectedBehavior): ExpectedBehavior {
|
|||||||
minCalls: behavior.minCalls,
|
minCalls: behavior.minCalls,
|
||||||
maxCalls: behavior.maxCalls,
|
maxCalls: behavior.maxCalls,
|
||||||
paramAssertions: behavior.paramAssertions.map((item) => ({ ...item })),
|
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) {
|
export function formatUpdatedAt(value?: string | null) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
TestCase,
|
TestCase,
|
||||||
TestCaseInputMode,
|
TestCaseInputMode,
|
||||||
TestSuite,
|
TestSuite,
|
||||||
|
ToolSimulation,
|
||||||
} from "@/data/test-suites";
|
} from "@/data/test-suites";
|
||||||
|
|
||||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
||||||
@@ -851,6 +852,7 @@ export type TestCaseWrite = {
|
|||||||
inputMode: TestCaseInputMode;
|
inputMode: TestCaseInputMode;
|
||||||
contextTurns: ContextTurn[];
|
contextTurns: ContextTurn[];
|
||||||
turns: FixedInputTurn[];
|
turns: FixedInputTurn[];
|
||||||
|
toolSimulations: ToolSimulation[];
|
||||||
overallCriteria: OverallCriterion[];
|
overallCriteria: OverallCriterion[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user