feat(testing): add fixed tool simulations

This commit is contained in:
Xin Wang
2026-08-26 09:36:37 +08:00
parent 26582bfb87
commit 239b9027ca
16 changed files with 1525 additions and 237 deletions
+39 -41
View File
@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
ErrorFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
@@ -54,7 +53,13 @@ from services.pipecat.processors import (
from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher
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 test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
@@ -197,18 +202,21 @@ class _TextCaptureProcessor(FrameProcessor):
"""Release every wait even when Pipecat reports the error upstream."""
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(
code="UNMOCKED_TOOL_CALL" if unmocked_tool else "PIPELINE_ERROR",
code=simulation_code or "PIPELINE_ERROR",
message=message,
stage=(
"tool"
if unmocked_tool
if simulation_code
else "model"
if frame.processor
else "pipeline"
),
retryable=not unmocked_tool,
retryable=simulation_code is None,
)
self.window.pending_tools.clear()
self.window.touch()
@@ -573,14 +581,12 @@ class TextPipelineRunner:
async def on_pipeline_error(_worker, frame):
capture.fail(frame)
executor_holder: dict[str, MockToolExecutor] = {}
text_client_tools = TextClientToolPort()
simulation_resolver = ToolSimulationResolver(definition.tool_simulations)
text_client_tools = TextClientToolPort(simulation_resolver)
workflow_vision_scope: dict[str, Any] = {"enabled": False}
def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store)
executor_holder["executor"] = executor
return executor
return MockToolExecutor(store, simulation_resolver)
async def queue_call_end(reason: str) -> None:
logger.debug(f"文本测试收到会话结束请求: {reason}")
@@ -738,8 +744,6 @@ class TextPipelineRunner:
tool_executor_factory=create_test_executor,
),
)
executor = executor_holder["executor"]
# A transportless text run cannot synthesize audio, but the configured
# greeting has already completed before scripted inputs begin. Mirror
# 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.
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
try:
startup_runtime_record_marker = len(capture.runtime_records)
simulation_resolver.enter_startup()
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)
runner_task = asyncio.create_task(
@@ -803,12 +793,14 @@ class TextPipelineRunner:
marker = capture.window.activity_count
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
simulation_resolver.raise_if_failed()
await _wait_for_startup(
worker,
capture,
runner_task,
marker,
)
simulation_resolver.raise_if_failed()
startup_greeting = _resolve_startup_greeting(
greeting,
capture.opening_speech_outputs,
@@ -844,7 +836,7 @@ class TextPipelineRunner:
worker,
runner_task,
capture,
executor,
simulation_resolver,
turn,
index,
image_assets=image_assets,
@@ -873,9 +865,24 @@ class TextPipelineRunner:
)
except TestExecutionError:
raise
except ToolSimulationError as exc:
raise TestExecutionError(
code=exc.code,
message=str(exc),
stage="tool",
retryable=False,
) from exc
except asyncio.CancelledError:
raise
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(
code="PIPELINE_EXECUTION_FAILED",
message=str(exc) or type(exc).__name__,
@@ -894,17 +901,14 @@ class TextPipelineRunner:
worker: PipelineWorker,
runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor,
executor: MockToolExecutor,
simulation_resolver: ToolSimulationResolver,
turn: FixedInputTurn,
index: int,
*,
image_assets: dict[str, bytes],
workflow_vision_scope: dict[str, Any],
) -> RawTurnResult:
tool_behaviors = [
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
]
executor.set_turn_behaviors(tool_behaviors)
simulation_resolver.enter_turn(turn.id)
activity_marker = capture.window.activity_count
output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls)
@@ -942,13 +946,7 @@ class TextPipelineRunner:
LLMMessagesAppendFrame(messages=[user_message], run_llm=True)
)
await _wait_for_quiescence(capture, runner_task, activity_marker)
if executor.unmocked_calls:
raise TestExecutionError(
code="UNMOCKED_TOOL_CALL",
message="本轮调用了未配置 Mock 的工具: "
+ "、".join(executor.unmocked_calls),
stage="tool",
)
simulation_resolver.raise_if_failed()
return RawTurnResult(
id=turn.id,
index=index,