feat: unify debug runtime timelines
This commit is contained in:
@@ -26,6 +26,7 @@ from pipecat.frames.frames import (
|
||||
LLMFullResponseStartFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
ManuallySwitchServiceFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
@@ -44,6 +45,7 @@ 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,
|
||||
@@ -102,6 +104,7 @@ class RawTurnResult:
|
||||
user_input: str
|
||||
assistant_reply: str
|
||||
tool_calls: list[RawToolCall]
|
||||
runtime_records: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,6 +114,7 @@ class TextStartupResult:
|
||||
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
|
||||
@@ -148,6 +152,30 @@ class _ResponseWindow:
|
||||
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."""
|
||||
|
||||
@@ -156,6 +184,8 @@ class _TextCaptureProcessor(FrameProcessor):
|
||||
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
|
||||
@@ -195,6 +225,14 @@ class _TextCaptureProcessor(FrameProcessor):
|
||||
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()
|
||||
@@ -248,6 +286,27 @@ class _TextCaptureProcessor(FrameProcessor):
|
||||
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] = {}
|
||||
@@ -494,9 +553,34 @@ class TextPipelineRunner:
|
||||
|
||||
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))
|
||||
|
||||
call_end = CallEndCoordinator(queue_call_end)
|
||||
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:
|
||||
@@ -667,6 +751,7 @@ class TextPipelineRunner:
|
||||
|
||||
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(
|
||||
@@ -693,12 +778,19 @@ class TextPipelineRunner:
|
||||
runner_task,
|
||||
marker,
|
||||
)
|
||||
startup_greeting = _resolve_startup_greeting(
|
||||
greeting,
|
||||
capture.opening_speech_outputs,
|
||||
)
|
||||
startup = TextStartupResult(
|
||||
greeting=greeting,
|
||||
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)
|
||||
@@ -775,6 +867,7 @@ class TextPipelineRunner:
|
||||
activity_marker = capture.window.activity_count
|
||||
output_marker = len(capture.outputs)
|
||||
tool_marker = len(capture.tool_calls)
|
||||
runtime_record_marker = len(capture.runtime_records)
|
||||
await worker.queue_frame(
|
||||
LLMMessagesAppendFrame(
|
||||
messages=[{"role": "user", "content": turn.user_input}],
|
||||
@@ -795,4 +888,7 @@ class TextPipelineRunner:
|
||||
user_input=turn.user_input,
|
||||
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:]
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user