Files
ai-video-fullstack/backend/services/test_runs/text_runner.py
T

963 lines
35 KiB
Python

"""Transportless Pipecat runner for one persisted fixed-text test case."""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from uuid import uuid4
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
DataFrame,
EndFrame,
ErrorFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
ManuallySwitchServiceFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import FunctionCallParams
from pipecat.workers.runner import WorkerRunner
from db.session import SessionLocal
from services.brains import BrainRuntime, build_brain
from services.knowledge import search as search_knowledge
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
FixedSpeechPlaybackMarkerFrame,
call_end_trace_event,
)
from services.pipecat.processors import (
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
)
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.vision import build_image_user_message
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
IDLE_SETTLE_SECONDS = 0.2
WORKER_START_TIMEOUT_SECONDS = 5.0
INTERNAL_CANCEL_REASON = "text_test_case_complete"
AUTOMATIC_KNOWLEDGE_HINT = (
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
"回答资料事实时只根据检索内容,资料不足要明确说明。"
)
ON_DEMAND_KNOWLEDGE_HINT = (
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
"资料不足要明确说明。"
)
@dataclass
class RawToolCall:
id: str
function_name: str
arguments: Any
result: Any = None
outcome: str = "error"
duration_ms: int = 0
_started_at: float = field(default_factory=time.monotonic, repr=False)
def as_result(self) -> dict[str, Any]:
return {
"id": self.id,
"functionName": self.function_name,
"argumentsJson": json.dumps(
self.arguments, ensure_ascii=False, indent=2, default=str
),
"resultJson": json.dumps(
self.result, ensure_ascii=False, indent=2, default=str
),
"outcome": self.outcome,
"durationMs": self.duration_ms,
}
@dataclass
class RawTurnResult:
id: str
index: int
user_input: str
assistant_reply: str
tool_calls: list[RawToolCall]
image: TestImageRef | None = None
runtime_records: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class TextStartupResult:
"""Headless startup choices applied before the scripted user turns."""
greeting: str = ""
assistant_outputs: list[str] = field(default_factory=list)
auto_confirmed_messages: list[dict[str, Any]] = field(default_factory=list)
runtime_records: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class TextCaseResult:
turns: list[RawTurnResult]
transcript: list[dict[str, str]]
startup: TextStartupResult = field(default_factory=TextStartupResult)
TurnCompleteCallback = Callable[[RawTurnResult], Awaitable[None]]
StartupCompleteCallback = Callable[[TextStartupResult], Awaitable[None]]
@dataclass
class _StartupBoundaryFrame(DataFrame):
"""Ordered marker queued behind every frame emitted by startup."""
reached: asyncio.Event
@dataclass
class _ResponseWindow:
active_llm: int = 0
active_assistant: int = 0
pending_tools: set[str] = field(default_factory=set)
activity_count: int = 0
last_activity_at: float = field(default_factory=time.monotonic)
def touch(self) -> None:
self.activity_count += 1
self.last_activity_at = time.monotonic()
@property
def idle(self) -> bool:
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
def _opening_speech_from_message(message: Any) -> str:
"""Extract only the transcript emitted by Prompt's opening Message stage."""
if (
not isinstance(message, dict)
or message.get("type") != "transcript"
or message.get("source") != "prompt-opening-speech"
):
return ""
return str(message.get("content") or "").strip()
def _runtime_record_from_message(message: Any) -> dict[str, Any] | None:
"""Keep the same runtime-record envelope used by live conversation history."""
if (
not isinstance(message, dict)
or message.get("type") != "workflow-event"
or message.get("internalStage") is True
):
return None
return dict(message)
class _TextCaptureProcessor(FrameProcessor):
"""Capture assistant/tool output and expose a reliable response boundary."""
def __init__(self, context: LLMContext):
super().__init__()
self.context = context
self.window = _ResponseWindow()
self.outputs: list[str] = []
self.opening_speech_outputs: list[str] = []
self.runtime_records: list[dict[str, Any]] = []
self.tool_calls: list[RawToolCall] = []
self._tool_by_id: dict[str, RawToolCall] = {}
self.error: TestExecutionError | None = None
self.on_fixed_speech_complete: Callable[[], Awaitable[None]] | None = None
def fail(self, frame: ErrorFrame) -> None:
"""Release every wait even when Pipecat reports the error upstream."""
message = frame.error or "Pipeline 执行失败"
unmocked_tool = "UNMOCKED_TOOL_CALL" in message
self.error = TestExecutionError(
code="UNMOCKED_TOOL_CALL" if unmocked_tool else "PIPELINE_ERROR",
message=message,
stage=(
"tool"
if unmocked_tool
else "model"
if frame.processor
else "pipeline"
),
retryable=not unmocked_tool,
)
self.window.pending_tools.clear()
self.window.touch()
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
text = frame.text.strip()
if text:
self.outputs.append(text)
if frame.append_to_context:
self.context.add_message({"role": "assistant", "content": text})
self.window.touch()
if self.on_fixed_speech_complete is not None:
await self.on_fixed_speech_complete()
return
if isinstance(frame, OutputTransportMessageUrgentFrame):
opening_speech = _opening_speech_from_message(frame.message)
if opening_speech:
self.opening_speech_outputs.append(opening_speech)
runtime_record = _runtime_record_from_message(frame.message)
if runtime_record is not None:
self.runtime_records.append(runtime_record)
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
await frame.completion.mark_played()
self.window.touch()
return
if isinstance(frame, LLMFullResponseStartFrame):
self.window.active_llm += 1
self.window.touch()
elif isinstance(frame, LLMFullResponseEndFrame):
self.window.active_llm = max(0, self.window.active_llm - 1)
self.window.touch()
await self.push_frame(frame, direction)
# There is no TTS/output transport. This releases provider/tool
# follow-up logic that normally waits for a speaking boundary.
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
return
elif isinstance(frame, FunctionCallInProgressFrame):
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
self.window.pending_tools.add(call.id)
self.window.touch()
elif isinstance(frame, FunctionCallResultFrame):
call = self._tool_by_id.get(frame.tool_call_id)
if call is None:
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
call.result = frame.result
call.duration_ms = max(0, round((time.monotonic() - call._started_at) * 1000))
status = frame.result.get("status") if isinstance(frame.result, dict) else None
call.outcome = "error" if status in {"error", "failed", "timeout"} else "success"
self.window.pending_tools.discard(call.id)
self.window.touch()
elif isinstance(frame, ErrorFrame):
self.fail(frame)
elif isinstance(frame, _StartupBoundaryFrame):
frame.reached.set()
self.window.touch()
elif isinstance(frame, (EndFrame, CancelFrame)):
self.window.touch()
await self.push_frame(frame, direction)
def _resolve_startup_greeting(
shared_greeting: str,
opening_speech_outputs: list[str],
) -> str:
"""Return the opening speech shown by the headless run.
Prompt confirmation mode deliberately suppresses the shared greeting
because its atomic Message stage owns both speech and the dialog. In text
tests the fixed speech transcript carries a dedicated source marker, so
preserve that exact output instead of guessing from unrelated fixed speech.
"""
greeting = shared_greeting.strip()
if greeting:
return greeting
return next(
(text.strip() for text in opening_speech_outputs if text.strip()),
"",
)
def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
latest_call_by_name: dict[str, str] = {}
for index, turn in enumerate(context_turns):
if turn.role in {"agent", "user"}:
messages.append(
{
"role": "assistant" if turn.role == "agent" else "user",
"content": turn.content,
}
)
continue
call_id = turn.tool_call_id or latest_call_by_name.get(turn.tool_name or "")
call_id = call_id or f"context_tool_{index}_{uuid4().hex[:8]}"
if turn.role == "tool_call":
latest_call_by_name[turn.tool_name or ""] = call_id
messages.append(
{
"role": "assistant",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": turn.tool_name,
"arguments": json.dumps(
json.loads(turn.content), ensure_ascii=False
),
},
}
],
}
)
else:
result_payload = json.loads(turn.content)
if turn.is_error:
result_payload = (
{**result_payload, "status": "error"}
if isinstance(result_payload, dict)
else {"status": "error", "data": result_payload}
)
messages.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps(result_payload, ensure_ascii=False),
}
)
return messages
def _transcript_content(message: dict[str, Any]) -> str:
"""Keep evaluator transcripts readable without persisting image data URIs."""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
text_parts = [
str(part.get("text") or "").strip()
for part in content
if isinstance(part, dict)
and part.get("type") in {"text", "input_text"}
and str(part.get("text") or "").strip()
]
has_image = any(
isinstance(part, dict)
and part.get("type") in {"image_url", "input_image"}
for part in content
)
if has_image:
text_parts.append("[用户提交了一张测试图片]")
return "\n".join(text_parts).strip()
async def _wait_for_quiescence(
capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None],
activity_marker: int,
*,
require_activity: bool = True,
) -> None:
while True:
if capture.error is not None:
raise capture.error
if runner_task.done():
await runner_task
return
has_activity = capture.window.activity_count > activity_marker
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
if capture.window.idle and settled and (has_activity or not require_activity):
return
await asyncio.sleep(0.05)
async def _wait_for_startup(
worker: PipelineWorker,
capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None],
activity_marker: int,
) -> None:
"""Wait until startup-queued frames and any entry LLM reply are complete."""
boundary = asyncio.Event()
await worker.queue_frame(_StartupBoundaryFrame(reached=boundary))
boundary_waiter = asyncio.create_task(boundary.wait())
done, _pending = await asyncio.wait(
{boundary_waiter, runner_task},
timeout=WORKER_START_TIMEOUT_SECONDS,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
boundary_waiter.cancel()
raise TestExecutionError(
code="STARTUP_DRAIN_TIMEOUT",
message="开场阶段未能进入稳定状态",
stage="pipeline",
retryable=True,
)
if boundary_waiter not in done:
boundary_waiter.cancel()
await asyncio.gather(boundary_waiter, return_exceptions=True)
await _wait_for_quiescence(
capture,
runner_task,
activity_marker,
require_activity=False,
)
class TextPipelineRunner:
"""Execute all fixed input turns in one isolated, transportless pipeline."""
async def run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
*,
timeout_seconds: int,
image_assets: dict[str, bytes] | None = None,
on_turn_complete: TurnCompleteCallback | None = None,
on_startup_complete: StartupCompleteCallback | None = None,
) -> TextCaseResult:
try:
async with asyncio.timeout(timeout_seconds):
return await self._run(
cfg,
definition,
image_assets=image_assets or {},
on_turn_complete=on_turn_complete,
on_startup_complete=on_startup_complete,
)
except TimeoutError as exc:
raise TestExecutionError(
code="PIPELINE_TIMEOUT",
message=f"测试用例执行超过 {timeout_seconds} 秒",
stage="pipeline",
retryable=True,
) from exc
async def _run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
*,
image_assets: dict[str, bytes],
on_turn_complete: TurnCompleteCallback | None,
on_startup_complete: StartupCompleteCallback | None,
) -> TextCaseResult:
if cfg.runtimeMode != "pipeline":
raise TestExecutionError(
code="UNSUPPORTED_RUNTIME_MODE",
message="第一版文本测试只支持 Pipeline 运行模式",
stage="pipeline",
)
if cfg.type not in {"prompt", "workflow"}:
raise TestExecutionError(
code="UNSUPPORTED_ASSISTANT_TYPE",
message=f"第一版文本测试暂不支持 {cfg.type} 类型助手",
stage="pipeline",
)
brain = build_brain(cfg)
seed_messages = _context_messages(definition.context_turns)
knowledge_config = cfg.knowledge_retrieval_config or {}
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
def with_knowledge_hint(text: str) -> str:
if cfg.type != "prompt" or not cfg.knowledge_base_id:
return text
hint = (
AUTOMATIC_KNOWLEDGE_HINT
if knowledge_mode == "automatic"
else ON_DEMAND_KNOWLEDGE_HINT
)
return "\n\n".join(part for part in (text, hint) if part)
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
context = LLMContext(
messages=(
seed_messages
if cfg.type == "workflow"
else [{"role": "system", "content": system_prompt}, *seed_messages]
)
)
graph_settings = cfg.graph.get("settings") or {}
default_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
llm_cfg = (
config_with_resource(cfg, default_resource)
if cfg.type == "workflow" and default_resource is not None
else cfg
)
llm = brain.build_llm(llm_cfg, context)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service: FrameProcessor = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
aggregators = LLMContextAggregatorPair(context)
user_aggregator = aggregators.user()
assistant_aggregator = aggregators.assistant()
capture = _TextCaptureProcessor(context)
knowledge = KnowledgeRetrievalProcessor(
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
score_threshold=float(
knowledge_config.get(
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
)
),
)
pipeline = Pipeline(
[
user_aggregator,
UserTurnRoutingProcessor(brain),
knowledge,
llm,
capture,
assistant_aggregator,
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
)
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
worker_started = asyncio.Event()
@worker.event_handler("on_pipeline_started")
async def on_pipeline_started(_worker, _frame):
worker_started.set()
@worker.event_handler("on_pipeline_error")
async def on_pipeline_error(_worker, frame):
capture.fail(frame)
executor_holder: dict[str, MockToolExecutor] = {}
text_client_tools = TextClientToolPort()
workflow_vision_scope: dict[str, Any] = {"enabled": False}
def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store)
executor_holder["executor"] = executor
return executor
async def queue_call_end(reason: str) -> None:
logger.debug(f"文本测试收到会话结束请求: {reason}")
await worker.queue_frame(
OutputTransportMessageUrgentFrame(
message=call_end_trace_event(
"ended",
reason=reason,
)
)
)
await worker.queue_frame(EndFrame(reason=reason))
async def record_call_end_state(
state: str,
interruptible: bool,
) -> None:
await worker.queue_frame(
OutputTransportMessageUrgentFrame(
message=call_end_trace_event(
state,
reason=call_end.reason,
interruptible=interruptible,
)
)
)
call_end = CallEndCoordinator(
queue_call_end,
on_state_change=record_call_end_state,
)
async def finish_fixed_speech_if_ending() -> None:
if call_end.ending:
await call_end.finish()
capture.on_fixed_speech_complete = finish_fixed_speech_if_ending
current_service = current_llm_service
async def switch_services(
llm_resource_id: str | None,
_asr_resource_id: str | None,
_tts_resource_id: str | None,
) -> None:
nonlocal current_service
target = (
llm_services.get(llm_resource_id)
if llm_resource_id
else current_llm_service
)
if target is None:
raise ValueError(f"Workflow LLM 资源未加载:{llm_resource_id}")
if target is current_service:
return
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_service = target
def set_system_prompt(text: str) -> None:
messages = context.get_messages()
text = with_knowledge_hint(text)
if messages and messages[0].get("role") == "system":
messages[0] = {"role": "system", "content": text}
else:
messages.insert(0, {"role": "system", "content": text})
knowledge_schema: FunctionSchema | None = None
if (
cfg.type == "prompt"
and cfg.knowledge_base_id
and knowledge_mode == "on_demand"
):
knowledge_schema = FunctionSchema(
name="search_knowledge_base",
description=(
"在当前助手绑定的知识库中检索资料。"
f"知识库:{cfg.knowledge_base_name}。"
f"{cfg.knowledge_base_description}"
),
properties={
"query": {
"type": "string",
"description": "完整问题或检索关键词",
}
},
required=["query"],
)
async def search_bound_knowledge(params: FunctionCallParams) -> None:
query = str(params.arguments.get("query") or "").strip()
if not query:
await params.result_callback(
{"status": "error", "message": "检索问题为空"}
)
return
try:
async with SessionLocal() as session:
results = await search_knowledge(
session,
cfg.knowledge_base_id or "",
query,
top_k=int(
knowledge_config.get(
"top_n",
knowledge_config.get("topN", 5),
)
),
score_threshold=float(
knowledge_config.get(
"score_threshold",
knowledge_config.get("scoreThreshold", 0.0),
)
),
)
await params.result_callback(
{"status": "ok", "results": results}
)
except Exception as exc: # noqa: BLE001 - return tool errors to LLM
logger.warning(f"文本测试知识库检索失败: {exc}")
await params.result_callback(
{"status": "error", "message": "知识库检索暂时不可用"}
)
llm.register_function("search_knowledge_base", search_bound_knowledge)
def set_tools(schemas=None) -> None:
visible = list(schemas or [])
if knowledge_schema is not None:
visible.append(knowledge_schema)
if visible:
context.set_tools(ToolsSchema(standard_tools=visible))
else:
context.set_tools()
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_tools,
call_end=call_end,
session_id=f"test_{uuid4().hex}",
client_tools=text_client_tools,
worker=worker,
context_aggregator=aggregators,
transport=None,
switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope,
set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=None,
set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
flow_global_functions=[],
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.
greeting = str(await brain.greeting(cfg) or "").strip()
if greeting and brain.spec.owns_context:
brain.prepare_greeting_context(greeting, context)
@assistant_aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(_aggregator):
capture.window.active_assistant += 1
capture.window.touch()
await brain.on_assistant_text_start(uuid4().hex)
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message):
content = str(message.content or "").strip()
capture.window.active_assistant = max(
0, capture.window.active_assistant - 1
)
if content:
capture.outputs.append(content)
capture.window.touch()
await brain.on_assistant_text_end(
uuid4().hex,
content,
bool(getattr(message, "interrupted", False)),
)
if call_end.ending:
# Text tests have no audio transport. A fully aggregated
# 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)
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(
runner.run(), name=f"text-test-{uuid4().hex[:12]}"
)
await asyncio.wait_for(
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
)
marker = capture.window.activity_count
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
await _wait_for_startup(
worker,
capture,
runner_task,
marker,
)
startup_greeting = _resolve_startup_greeting(
greeting,
capture.opening_speech_outputs,
)
startup = TextStartupResult(
greeting=startup_greeting,
assistant_outputs=list(capture.outputs),
auto_confirmed_messages=list(
text_client_tools.auto_confirmed_messages
),
runtime_records=list(
capture.runtime_records[startup_runtime_record_marker:]
),
)
if on_startup_complete is not None:
await on_startup_complete(startup)
# Startup messages initialize the session but are not a scripted
# user turn result in the fixed-input editor contract.
capture.outputs.clear()
capture.tool_calls.clear()
results: list[RawTurnResult] = []
for index, turn in enumerate(definition.turns):
if worker.has_finished():
raise TestExecutionError(
code="PIPELINE_ENDED_EARLY",
message=f"Pipeline 在第 {index + 1} 轮输入前已经结束",
stage="pipeline",
retryable=False,
)
result = await self._run_turn(
cfg,
worker,
runner_task,
capture,
executor,
turn,
index,
image_assets=image_assets,
workflow_vision_scope=workflow_vision_scope,
)
results.append(result)
if on_turn_complete is not None:
await on_turn_complete(result)
transcript = []
for message in context.get_messages():
if message.get("role") not in {"user", "assistant"}:
continue
content = _transcript_content(message)
if content:
transcript.append(
{
"role": str(message.get("role") or ""),
"content": content,
}
)
return TextCaseResult(
turns=results,
transcript=transcript,
startup=startup,
)
except TestExecutionError:
raise
except asyncio.CancelledError:
raise
except Exception as exc:
raise TestExecutionError(
code="PIPELINE_EXECUTION_FAILED",
message=str(exc) or type(exc).__name__,
stage="model",
retryable=True,
) from exc
finally:
if not worker.has_finished():
await worker.cancel(reason=INTERNAL_CANCEL_REASON)
if runner_task is not None:
await asyncio.gather(runner_task, return_exceptions=True)
async def _run_turn(
self,
cfg: AssistantConfig,
worker: PipelineWorker,
runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor,
executor: MockToolExecutor,
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)
activity_marker = capture.window.activity_count
output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls)
runtime_record_marker = len(capture.runtime_records)
user_message: dict[str, Any] = {
"role": "user",
"content": turn.user_input,
}
if turn.image is not None:
image_bytes = image_assets.get(turn.image.asset_id)
if image_bytes is None:
raise TestExecutionError(
code="TEST_IMAGE_MISSING",
message=f"测试图片不存在: {turn.image.asset_id}",
stage="pipeline",
retryable=False,
)
try:
user_message = await build_image_user_message(
cfg,
image_bytes,
turn.user_input,
workflow_scope=(
workflow_vision_scope if cfg.type == "workflow" else None
),
)
except ValueError as exc:
raise TestExecutionError(
code="TEST_IMAGE_UNSUPPORTED",
message=str(exc),
stage="model",
retryable=False,
) from exc
await worker.queue_frame(
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",
)
return RawTurnResult(
id=turn.id,
index=index,
user_input=turn.user_input,
image=turn.image,
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
tool_calls=list(capture.tool_calls[tool_marker:]),
runtime_records=list(
capture.runtime_records[runtime_record_marker:]
),
)