fix: changes to update pipecat version to 0.0.100 (#122)

* feat: add stt evals

* add smart turn as provider

* chore: remove deprecations

* chore: format files

* fix: remove deprecated UserIdleProcessor

* fix: remove deprecated TranscriptProcessor

* chore: update pipecat submodule

* feat: add evals visualisation

* fix: trigger llm generation on client connected and pipeline started

* chore: update pipecat

* chore: update pipecat submodule

* Add tests

* fix: slow loading of workflow page

* chore: update pipecat submodule

* Show version after release

* Fixes #99

* fix: provider check for websocket connection

* Fixes #107

* Fix #96

* chore: fix documentation

* fix: cloudonix campaign call error

---------

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-01-23 18:53:59 +05:30 committed by GitHub
parent a4367bd83b
commit 911c5ed416
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 16919 additions and 597 deletions

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Any, Dict
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from unittest.mock import Mock
import pytest
@ -28,6 +28,87 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
START_CALL_SYSTEM_PROMPT = "start_call_system_prompt"
END_CALL_SYSTEM_PROMPT = "end_call_system_prompt"
# Default workflow definition for mocking database WorkflowModel
DEFAULT_WORKFLOW_DEFINITION = {
"nodes": [
{
"id": "1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {
"name": "Start",
"prompt": START_CALL_SYSTEM_PROMPT,
"is_start": True,
"allow_interrupt": False,
"add_global_prompt": False,
},
},
{
"id": "2",
"type": "endCall",
"position": {"x": 0, "y": 200},
"data": {
"name": "End",
"prompt": END_CALL_SYSTEM_PROMPT,
"is_end": True,
"allow_interrupt": False,
"add_global_prompt": False,
},
},
],
"edges": [
{
"id": "1-2",
"source": "1",
"target": "2",
"data": {"label": "End", "condition": "End the call"},
}
],
}
@dataclass
class MockWorkflowModel:
"""Mock database WorkflowModel for testing.
This mimics the structure of the database WorkflowModel, not the parsed WorkflowGraph.
Use this when mocking db_client.get_workflow() responses.
"""
workflow_id: int = 1
organization_id: int = 1
workflow_configurations: Dict[str, Any] = field(default_factory=dict)
workflow_definition_with_fallback: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not self.workflow_definition_with_fallback:
self.workflow_definition_with_fallback = DEFAULT_WORKFLOW_DEFINITION.copy()
@dataclass
class MockWorkflowRun:
"""Mock database WorkflowRun for testing.
Use this when mocking db_client.get_workflow_run() responses.
"""
is_completed: bool = False
initial_context: Dict[str, Any] = field(default_factory=dict)
gathered_context: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MockUserConfig:
"""Mock user configuration for testing.
Use this when mocking db_client.get_user_configurations() responses.
"""
stt: Optional[Any] = None
tts: Optional[Any] = None
llm: Optional[Any] = None
embeddings: Optional[Any] = None
class MockTransportProcessor(FrameProcessor):
"""
@ -41,7 +122,7 @@ class MockTransportProcessor(FrameProcessor):
Args:
emit_bot_speaking: If True, also emits BotSpeakingFrame on TTSAudioRawFrame
which is needed for UserIdleProcessor to start conversation tracking. Default True.
which is needed for user idle tracking to start conversation tracking. Default True.
"""
def __init__(
@ -63,7 +144,7 @@ class MockTransportProcessor(FrameProcessor):
BotStartedSpeakingFrame(), direction=FrameDirection.UPSTREAM
)
elif isinstance(frame, TTSAudioRawFrame):
# Emit BotSpeakingFrame - this is what triggers the UserIdleProcessor
# Emit BotSpeakingFrame - this is what triggers user idle tracking
# to start conversation tracking
if self._emit_bot_speaking:
await self.push_frame(BotSpeakingFrame())
@ -101,6 +182,24 @@ def mock_engine():
return engine
@pytest.fixture
def mock_workflow_model():
"""Create a mock WorkflowModel for testing database responses."""
return MockWorkflowModel()
@pytest.fixture
def mock_workflow_run():
"""Create a mock WorkflowRun for testing database responses."""
return MockWorkflowRun()
@pytest.fixture
def mock_user_config():
"""Create a mock user configuration for testing."""
return MockUserConfig()
@pytest.fixture
def sample_tools():
"""Create sample mock tools for testing."""

View file

@ -42,7 +42,6 @@ from pipecat.processors.aggregators.llm_response_universal import (
)
from pipecat.tests import MockLLMService, MockTTSService
# Define prompts for test nodes
START_NODE_PROMPT = "Start Node System Prompt"
AGENT_NODE_PROMPT = "Agent Node System Prompt"
@ -143,14 +142,20 @@ class ContextCapturingMockLLM(MockLLMService):
msg_copy = dict(msg)
# Copy content to avoid reference issues
if "content" in msg_copy:
msg_copy["content"] = str(msg_copy["content"]) if msg_copy["content"] else None
msg_copy["content"] = (
str(msg_copy["content"]) if msg_copy["content"] else None
)
messages_snapshot.append(msg_copy)
self.captured_contexts.append({
"step": self._current_step,
"messages": messages_snapshot,
"system_prompt": messages_snapshot[0]["content"] if messages_snapshot else None,
})
self.captured_contexts.append(
{
"step": self._current_step,
"messages": messages_snapshot,
"system_prompt": messages_snapshot[0]["content"]
if messages_snapshot
else None,
}
)
# Call parent implementation to stream the mock chunks
return await super()._stream_chat_completions_universal_context(context)
@ -306,14 +311,26 @@ class TestContextUpdateBeforeNextCompletion:
transition completes. The test verifies the context is still correctly updated.
"""
# Step 0 (Start node): call collect_info to transition to agent
step_0_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "collect_info", "arguments": {}, "tool_call_id": "call_transition_1"},
])
step_0_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "collect_info",
"arguments": {},
"tool_call_id": "call_transition_1",
},
]
)
# Step 1 (Agent node): call end_call to transition to end
step_1_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "end_call", "arguments": {}, "tool_call_id": "call_transition_2"},
])
step_1_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "end_call",
"arguments": {},
"tool_call_id": "call_transition_2",
},
]
)
# Step 2 (End node): text response (end node has no outgoing edges)
step_2_chunks = MockLLMService.create_text_chunks("Goodbye!")
@ -327,7 +344,7 @@ class TestContextUpdateBeforeNextCompletion:
)
# Should have been called 3 times: start node, agent node, end node
assert llm.get_current_step() == 2, (
assert llm.get_current_step() == 3, (
f"Expected 3 LLM generations (start, agent, end), got {llm.get_current_step()}"
)
@ -376,14 +393,26 @@ class TestContextUpdateBeforeNextCompletion:
is handled correctly.
"""
# Step 0 (Start node): call collect_info to transition to agent
step_0_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "collect_info", "arguments": {}, "tool_call_id": "call_transition_1"},
])
step_0_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "collect_info",
"arguments": {},
"tool_call_id": "call_transition_1",
},
]
)
# Step 1 (Agent node): call end_call to transition to end
step_1_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "end_call", "arguments": {}, "tool_call_id": "call_transition_2"},
])
step_1_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "end_call",
"arguments": {},
"tool_call_id": "call_transition_2",
},
]
)
# Step 2 (End node): text response
step_2_chunks = MockLLMService.create_text_chunks("Goodbye!")
@ -397,7 +426,7 @@ class TestContextUpdateBeforeNextCompletion:
)
# Verify all three nodes were executed
assert llm.get_current_step() == 2, (
assert llm.get_current_step() == 3, (
f"Expected 3 steps, got {llm.get_current_step()}"
)
@ -408,8 +437,7 @@ class TestContextUpdateBeforeNextCompletion:
assert AGENT_NODE_PROMPT in llm.get_system_prompt_at_step(1)
# Step 2: End node - should have end prompt
# FIXME - EndFrame is getting processed before LLMContextFrame
# assert END_NODE_PROMPT in llm.get_system_prompt_at_step(2)
assert END_NODE_PROMPT in llm.get_system_prompt_at_step(2)
# Verify each subsequent step has the previous tool results
step_1_ctx = llm.get_context_at_step(1)
@ -423,14 +451,14 @@ class TestContextUpdateBeforeNextCompletion:
assert step_1_has_tool, "Agent node should see collect_info tool result"
# Step 2 should have tool results from both transitions
# FIXME - EndFrame is getting processed before LLMContextFrame
# step_2_tool_messages = [
# msg for msg in step_2_ctx["messages"]
# if msg.get("role") == "tool" or msg.get("tool_call_id")
# ]
# assert len(step_2_tool_messages) >= 2, (
# f"End node should see at least 2 tool results, got {len(step_2_tool_messages)}"
# )
step_2_tool_messages = [
msg
for msg in step_2_ctx["messages"]
if msg.get("role") == "tool" or msg.get("tool_call_id")
]
assert len(step_2_tool_messages) >= 2, (
f"End node should see at least 2 tool results, got {len(step_2_tool_messages)}"
)
@pytest.mark.asyncio
async def test_context_messages_preserve_conversation_history(
@ -444,14 +472,26 @@ class TestContextUpdateBeforeNextCompletion:
- Tool call messages and results
"""
# Step 0 (Start node): call collect_info to transition to agent
step_0_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "collect_info", "arguments": {}, "tool_call_id": "call_transition_1"},
])
step_0_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "collect_info",
"arguments": {},
"tool_call_id": "call_transition_1",
},
]
)
# Step 1 (Agent node): call end_call to transition to end
step_1_chunks = MockLLMService.create_multiple_function_call_chunks([
{"name": "end_call", "arguments": {}, "tool_call_id": "call_transition_2"},
])
step_1_chunks = MockLLMService.create_multiple_function_call_chunks(
[
{
"name": "end_call",
"arguments": {},
"tool_call_id": "call_transition_2",
},
]
)
# Step 2 (End node): text response
step_2_chunks = MockLLMService.create_text_chunks("Goodbye!")
@ -472,18 +512,15 @@ class TestContextUpdateBeforeNextCompletion:
assert len(ctx_1["messages"]) > len(ctx_0["messages"]), (
"Context at step 1 should have more messages than step 0"
)
# FIXME
# assert len(ctx_2["messages"]) > len(ctx_1["messages"]), (
# "Context at step 2 should have more messages than step 1"
# )
assert len(ctx_2["messages"]) > len(ctx_1["messages"]), (
"Context at step 2 should have more messages than step 1"
)
# Verify assistant messages are accumulated
# FIXME
# assistant_messages_at_step_2 = [
# msg for msg in ctx_2["messages"]
# if msg.get("role") == "assistant"
# ]
# assert len(assistant_messages_at_step_2) >= 2, (
# "Should have at least 2 assistant messages by step 2"
# )
assistant_messages_at_step_2 = [
msg for msg in ctx_2["messages"] if msg.get("role") == "assistant"
]
assert len(assistant_messages_at_step_2) >= 2, (
"Should have at least 2 assistant messages by step 2"
)

View file

@ -0,0 +1,100 @@
import asyncio
import pytest
from loguru import logger
from pipecat.frames.frames import (
EndTaskFrame,
Frame,
InterruptionTaskFrame,
LLMRunFrame,
)
from pipecat.pipeline.base_task import PipelineTaskParams
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class MockTransport(FrameProcessor):
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
class BusyWaitProcessor(FrameProcessor):
def __init__(self, wait_time=5.0, **kwargs):
super().__init__(**kwargs)
self._wait_time = wait_time
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, LLMRunFrame):
# Simulate a delay, which can happen sometimes due to slow LLM Inferencing or
# other reasons
try:
logger.debug(f"{self} sleeping with frame: {frame}")
await asyncio.sleep(5)
logger.debug(f"{self} woke up with frame: {frame}")
except asyncio.CancelledError:
logger.debug(f"{self} was cancelled")
raise
await self.push_frame(frame, direction)
@pytest.mark.asyncio
async def test_interruption_with_blocked_end_frame():
busy_wait_processor = BusyWaitProcessor(wait_time=5)
transport = MockTransport()
pipeline = Pipeline([transport, busy_wait_processor])
task = PipelineTask(pipeline)
async def run_pipeline():
loop = asyncio.get_running_loop()
params = PipelineTaskParams(loop=loop)
await task.run(params=params)
async def queue_frame():
await task.queue_frames([LLMRunFrame()])
# Send EndTaskFrame to simulate EndFrame
await asyncio.sleep(0.1)
await transport.queue_frame(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
# Simulate an Interruption, which can happen if the user
# has started to speak
await asyncio.sleep(0.1)
await transport.queue_frame(
InterruptionTaskFrame(), direction=FrameDirection.UPSTREAM
)
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
queue_task = asyncio.create_task(queue_frame())
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, queue_task],
timeout=1.0,
return_when=asyncio.ALL_COMPLETED,
)
# If there are pending tasks, we timed out
if pending:
# Cancel all pending tasks
for t in pending:
t.cancel()
# Give limited time for cleanup, then move on regardless
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
pytest.fail("Test timed out after 1 second")

View file

@ -1,10 +1,10 @@
"""
Simulates a user idle condition and tests the behaviour
of the user idle processor.
of the user idle handler.
This module tests the behavior when the user becomes idle during a conversation,
ensuring the UserIdleProcessor properly triggers the callback and the engine
handles it correctly.
ensuring the user_idle_timeout in LLMUserAggregatorParams properly triggers
the on_user_turn_idle event and the engine handles it correctly.
"""
import asyncio
@ -23,8 +23,8 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response import LLMAssistantAggregatorParams
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.processors.user_idle_processor import UserIdleProcessor
from pipecat.tests import MockLLMService, MockTTSService
@ -32,8 +32,8 @@ async def run_pipeline_with_user_idle(
workflow: WorkflowGraph,
user_idle_timeout: float = 0.2,
mock_steps: list | None = None,
) -> tuple[MockLLMService, LLMContext, UserIdleProcessor]:
"""Run a pipeline with UserIdleProcessor and simulate user idle condition.
) -> tuple[MockLLMService, LLMContext]:
"""Run a pipeline with user_idle_timeout and simulate user idle condition.
Args:
workflow: The workflow graph to use.
@ -42,7 +42,7 @@ async def run_pipeline_with_user_idle(
defaults to a simple greeting followed by text responses.
Returns:
Tuple of (MockLLMService, LLMContext, UserIdleProcessor) for assertions.
Tuple of (MockLLMService, LLMContext) for assertions.
"""
# Create mock responses - bot will speak first, then respond to idle prompts
# Step 1: Initial greeting
@ -64,10 +64,11 @@ async def run_pipeline_with_user_idle(
# Create LLM context
context = LLMContext()
# Create context aggregator with both user and assistant aggregators
# Create context aggregator with user_idle_timeout in user_params
assistant_params = LLMAssistantAggregatorParams(expect_stripped_words=True)
user_params = LLMUserAggregatorParams(user_idle_timeout=user_idle_timeout)
context_aggregator = LLMContextAggregatorPair(
context, assistant_params=assistant_params
context, assistant_params=assistant_params, user_params=user_params
)
user_context_aggregator = context_aggregator.user()
assistant_context_aggregator = context_aggregator.assistant()
@ -81,18 +82,20 @@ async def run_pipeline_with_user_idle(
workflow_run_id=1,
)
# Create UserIdleProcessor with engine's callback and a short timeout
user_idle_processor = UserIdleProcessor(
callback=engine.create_user_idle_callback(),
timeout=user_idle_timeout,
)
# Register user idle event handlers
user_idle_handler = engine.create_user_idle_handler()
# Build the pipeline:
# llm -> mock_transport -> user_idle_processor -> assistant_context_aggregator
# The user_context_aggregator would normally be at the start for user input
@user_context_aggregator.event_handler("on_user_turn_idle")
async def on_user_turn_idle(aggregator):
await user_idle_handler.handle_idle(aggregator)
@user_context_aggregator.event_handler("on_user_turn_started")
async def on_user_turn_started(aggregator, strategy):
user_idle_handler.reset()
# Build the pipeline
pipeline = Pipeline(
[
user_idle_processor,
user_context_aggregator,
llm,
tts,
@ -154,11 +157,11 @@ async def run_pipeline_with_user_idle(
return_exceptions=True,
)
return llm, context, user_idle_processor
return llm, context
class TestUserIdleHandler:
"""Test user idle handling through PipecatEngine and UserIdleProcessor."""
"""Test user idle handling through PipecatEngine and UserIdleHandler."""
@pytest.mark.asyncio
async def test_user_idle_triggers_callback(self, simple_workflow: WorkflowGraph):
@ -167,13 +170,13 @@ class TestUserIdleHandler:
This test verifies that when:
1. The bot starts speaking (triggers conversation tracking)
2. No user input is received for the timeout period
3. The UserIdleProcessor triggers the idle callback
3. The on_user_turn_idle event triggers the idle handler
The engine's user idle callback should:
The engine's user idle handler should:
- First retry: Send a message asking if user is still there
- Second retry: Send goodbye message and end the call
"""
llm, context, user_idle_processor = await run_pipeline_with_user_idle(
llm, context = await run_pipeline_with_user_idle(
workflow=simple_workflow,
user_idle_timeout=0.2, # Short timeout for faster test
)
@ -220,7 +223,7 @@ class TestUserIdleHandler:
MockLLMService.create_text_chunks("Response 3"),
]
llm, context, user_idle_processor = await run_pipeline_with_user_idle(
llm, context = await run_pipeline_with_user_idle(
workflow=three_node_workflow,
user_idle_timeout=0.2,
mock_steps=mock_steps,