Merge branch 'main' into krisp-viva-vad-support

This commit is contained in:
Garegin Harutyunyan
2026-03-23 18:35:58 +04:00
committed by GitHub
741 changed files with 70990 additions and 21694 deletions

View File

@@ -0,0 +1 @@

192
tests/genesys/conftest.py Normal file
View File

@@ -0,0 +1,192 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Pytest fixtures for Genesys AudioHook serializer tests.
These fixtures provide sample AudioHook protocol messages for testing
the GenesysAudioHookSerializer. They are scoped to this directory only.
"""
import pytest
@pytest.fixture
def sample_open_message():
"""Sample AudioHook open message from Genesys."""
return {
"version": "2",
"type": "open",
"seq": 1,
"id": "test-session-123",
"parameters": {
"conversationId": "conv-456",
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
},
"media": [
{
"type": "audio",
"format": "PCMU",
"channels": ["external"],
"rate": 8000,
}
],
},
}
@pytest.fixture
def sample_open_message_with_input_variables():
"""Sample AudioHook open message with custom inputVariables from Genesys."""
return {
"version": "2",
"type": "open",
"seq": 1,
"id": "test-session-123",
"parameters": {
"conversationId": "conv-456",
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
},
"media": [
{
"type": "audio",
"format": "PCMU",
"channels": ["external"],
"rate": 8000,
}
],
"inputVariables": {
"customer_id": "cust-789",
"queue_name": "billing",
"priority": "high",
"language": "es-ES",
},
},
}
@pytest.fixture
def sample_ping_message():
"""Sample AudioHook ping message."""
return {
"version": "2",
"type": "ping",
"seq": 5,
"id": "test-session-123",
"position": "PT10.5S",
}
@pytest.fixture
def sample_close_message():
"""Sample AudioHook close message from Genesys."""
return {
"version": "2",
"type": "close",
"seq": 10,
"id": "test-session-123",
"position": "PT30.0S",
"parameters": {
"reason": "disconnect",
},
}
@pytest.fixture
def sample_pause_message():
"""Sample AudioHook pause message."""
return {
"version": "2",
"type": "pause",
"seq": 7,
"id": "test-session-123",
"position": "PT15.0S",
"parameters": {
"reason": "hold",
},
}
@pytest.fixture
def sample_update_message():
"""Sample AudioHook update message."""
return {
"version": "2",
"type": "update",
"seq": 8,
"id": "test-session-123",
"position": "PT20.0S",
"parameters": {
"participant": {
"ani": "+1234567890",
"dnis": "+0987654321",
"name": "John Doe",
},
},
}
@pytest.fixture
def sample_error_message():
"""Sample AudioHook error message."""
return {
"version": "2",
"type": "error",
"seq": 9,
"id": "test-session-123",
"parameters": {
"code": 500,
"message": "Internal server error",
},
}
@pytest.fixture
def sample_dtmf_message():
"""Sample AudioHook DTMF message."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "5",
},
}
@pytest.fixture
def sample_dtmf_star_message():
"""Sample AudioHook DTMF message with star key."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "*",
},
}
@pytest.fixture
def sample_dtmf_hash_message():
"""Sample AudioHook DTMF message with hash key."""
return {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"position": "PT12.0S",
"parameters": {
"digit": "#",
},
}

View File

@@ -0,0 +1,376 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for the Genesys AudioHook serializer."""
import json
import pytest
from pipecat.frames.frames import InputDTMFFrame, OutputTransportMessageUrgentFrame
from pipecat.serializers.genesys import AudioHookChannel, GenesysAudioHookSerializer
class TestGenesysAudioHookSerializer:
"""Tests for GenesysAudioHookSerializer."""
# ==================== Initialization Tests ====================
def test_create_serializer_default_params(self):
"""Test creating serializer with default parameters."""
serializer = GenesysAudioHookSerializer()
# session_id is auto-generated as UUID
assert serializer.session_id != ""
assert len(serializer.session_id) == 36 # UUID format
assert serializer.is_open is False
assert serializer.is_paused is False
def test_create_serializer_with_custom_params(self):
"""Test creating serializer with custom parameters."""
params = GenesysAudioHookSerializer.InputParams(
channel=AudioHookChannel.BOTH,
sample_rate=16000,
supported_languages=["es-ES", "en-US"],
selected_language="es-ES",
start_paused=True,
)
serializer = GenesysAudioHookSerializer(params=params)
assert serializer.session_id != ""
# ==================== Response Creation Tests ====================
def test_create_opened_response(self):
"""Test creating an opened response message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_opened_response()
assert msg["type"] == "opened"
assert msg["version"] == "2"
assert msg["id"] == serializer.session_id
assert "parameters" in msg
assert serializer.is_open is True
def test_create_opened_response_with_languages(self):
"""Test creating an opened response with language options."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_opened_response(
supported_languages=["es", "en", "fr"],
selected_language="es",
)
assert msg["parameters"]["supportedLanguages"] == ["es", "en", "fr"]
assert msg["parameters"]["selectedLanguage"] == "es"
def test_create_pong_response(self):
"""Test creating a pong response message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_pong_response()
assert msg["type"] == "pong"
assert msg["id"] == serializer.session_id
assert msg["parameters"] == {}
def test_create_closed_response(self):
"""Test creating a closed response message."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
msg = serializer.create_closed_response()
assert msg["type"] == "closed"
assert serializer.is_open is False
assert msg["parameters"] == {} # Empty parameters when no output_variables
def test_create_closed_response_with_output_variables(self):
"""Test creating a closed response with custom output variables."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
msg = serializer.create_closed_response(
output_variables={
"intent": "billing_inquiry",
"customer_verified": True,
"summary": "Customer asked about their bill",
}
)
assert msg["type"] == "closed"
assert msg["parameters"]["outputVariables"]["intent"] == "billing_inquiry"
assert msg["parameters"]["outputVariables"]["customer_verified"] is True
assert msg["parameters"]["outputVariables"]["summary"] == "Customer asked about their bill"
def test_create_resumed_response(self):
"""Test creating a resumed response message."""
serializer = GenesysAudioHookSerializer()
serializer._is_paused = True
msg = serializer.create_resumed_response()
assert msg["type"] == "resumed"
assert serializer.is_paused is False
def test_create_disconnect_message(self):
"""Test creating a disconnect message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_disconnect_message(
reason="completed",
action="transfer",
)
assert msg["type"] == "disconnect"
assert msg["parameters"]["reason"] == "completed"
assert msg["parameters"]["outputVariables"]["action"] == "transfer"
def test_create_disconnect_message_with_output_variables(self):
"""Test creating a disconnect message with custom output variables."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_disconnect_message(
reason="completed",
action="finished",
output_variables={"result": "success", "code": "123"},
)
assert msg["parameters"]["outputVariables"]["result"] == "success"
assert msg["parameters"]["outputVariables"]["code"] == "123"
def test_create_error_message(self):
"""Test creating an error message."""
serializer = GenesysAudioHookSerializer()
msg = serializer.create_error_message(
code=500,
message="Internal error",
retryable=True,
)
assert msg["type"] == "error"
assert msg["parameters"]["code"] == 500
assert msg["parameters"]["message"] == "Internal error"
assert msg["parameters"]["retryable"] is True
# ==================== Message Handling Tests ====================
@pytest.mark.asyncio
async def test_handle_open_message(self, sample_open_message):
"""Test handling an open message returns opened frame."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_open_message))
# Now returns OutputTransportMessageUrgentFrame with opened response
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "opened"
assert serializer.session_id == "test-session-123"
assert serializer.conversation_id == "conv-456"
@pytest.mark.asyncio
async def test_handle_open_message_extracts_participant(self, sample_open_message):
"""Test that open message extracts participant info."""
serializer = GenesysAudioHookSerializer()
await serializer.deserialize(json.dumps(sample_open_message))
assert serializer.participant is not None
assert serializer.participant["ani"] == "+1234567890"
assert serializer.participant["dnis"] == "+0987654321"
@pytest.mark.asyncio
async def test_handle_open_message_uses_params(self, sample_open_message):
"""Test that open message uses InputParams for response."""
params = GenesysAudioHookSerializer.InputParams(
supported_languages=["es-ES", "en-US"],
selected_language="es-ES",
start_paused=True,
)
serializer = GenesysAudioHookSerializer(params=params)
result = await serializer.deserialize(json.dumps(sample_open_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["parameters"]["supportedLanguages"] == ["es-ES", "en-US"]
assert result.message["parameters"]["selectedLanguage"] == "es-ES"
assert result.message["parameters"]["startPaused"] is True
@pytest.mark.asyncio
async def test_handle_open_message_extracts_input_variables(
self, sample_open_message_with_input_variables
):
"""Test that open message extracts inputVariables from Genesys."""
serializer = GenesysAudioHookSerializer()
await serializer.deserialize(json.dumps(sample_open_message_with_input_variables))
assert serializer.input_variables is not None
assert serializer.input_variables["customer_id"] == "cust-789"
assert serializer.input_variables["queue_name"] == "billing"
assert serializer.input_variables["priority"] == "high"
assert serializer.input_variables["language"] == "es-ES"
@pytest.mark.asyncio
async def test_handle_ping_message(self, sample_ping_message):
"""Test handling a ping message returns pong frame."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_ping_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "pong"
@pytest.mark.asyncio
async def test_handle_close_message(self, sample_close_message):
"""Test handling a close message returns closed frame."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
result = await serializer.deserialize(json.dumps(sample_close_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "closed"
assert serializer.is_open is False
@pytest.mark.asyncio
async def test_handle_close_message_includes_output_variables(self, sample_close_message):
"""Test that close response includes output variables when set."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
# Set output variables before close
serializer.set_output_variables(
{"intent": "support", "resolved": True, "transfer_to": "agent_queue"}
)
result = await serializer.deserialize(json.dumps(sample_close_message))
assert isinstance(result, OutputTransportMessageUrgentFrame)
assert result.message["type"] == "closed"
assert result.message["parameters"]["outputVariables"]["intent"] == "support"
assert result.message["parameters"]["outputVariables"]["resolved"] is True
assert result.message["parameters"]["outputVariables"]["transfer_to"] == "agent_queue"
# ==================== Output Variables Tests ====================
def test_set_output_variables(self):
"""Test setting output variables."""
serializer = GenesysAudioHookSerializer()
assert serializer.output_variables is None
serializer.set_output_variables({"intent": "billing", "score": 0.95})
assert serializer.output_variables is not None
assert serializer.output_variables["intent"] == "billing"
assert serializer.output_variables["score"] == 0.95
def test_set_output_variables_overwrites(self):
"""Test that setting output variables overwrites previous values."""
serializer = GenesysAudioHookSerializer()
serializer.set_output_variables({"first": "value"})
serializer.set_output_variables({"second": "value"})
assert "first" not in serializer.output_variables
assert serializer.output_variables["second"] == "value"
@pytest.mark.asyncio
async def test_handle_pause_message(self, sample_pause_message):
"""Test handling a pause message."""
serializer = GenesysAudioHookSerializer()
serializer._is_open = True
result = await serializer.deserialize(json.dumps(sample_pause_message))
assert result is None # Pause is handled internally
assert serializer.is_paused is True
@pytest.mark.asyncio
async def test_handle_update_message(self, sample_update_message):
"""Test handling an update message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_update_message))
assert result is None # Update is handled internally
assert serializer.participant is not None
assert serializer.participant["name"] == "John Doe"
@pytest.mark.asyncio
async def test_handle_error_message(self, sample_error_message):
"""Test handling an error message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_error_message))
assert result is None # Error is logged but returns None
# ==================== DTMF Tests ====================
@pytest.mark.asyncio
async def test_handle_dtmf_digit(self, sample_dtmf_message):
"""Test handling a DTMF digit message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "5"
@pytest.mark.asyncio
async def test_handle_dtmf_star(self, sample_dtmf_star_message):
"""Test handling a DTMF star (*) message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_star_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "*"
@pytest.mark.asyncio
async def test_handle_dtmf_hash(self, sample_dtmf_hash_message):
"""Test handling a DTMF hash (#) message."""
serializer = GenesysAudioHookSerializer()
result = await serializer.deserialize(json.dumps(sample_dtmf_hash_message))
assert isinstance(result, InputDTMFFrame)
assert result.button.value == "#"
@pytest.mark.asyncio
async def test_handle_dtmf_empty_digit(self):
"""Test handling a DTMF message without digit."""
serializer = GenesysAudioHookSerializer()
dtmf_msg = {
"version": "2",
"type": "dtmf",
"seq": 6,
"id": "test-session-123",
"parameters": {},
}
result = await serializer.deserialize(json.dumps(dtmf_msg))
assert result is None # No digit provided
# ==================== Sequence Number Tests ====================
def test_sequence_numbers_increment(self):
"""Test that sequence numbers increment correctly."""
serializer = GenesysAudioHookSerializer()
response1 = serializer.create_pong_response()
response2 = serializer.create_pong_response()
response3 = serializer.create_pong_response()
assert response1["seq"] == 1
assert response2["seq"] == 2
assert response3["seq"] == 3

View File

@@ -5,7 +5,6 @@
#
import os
from unittest.mock import AsyncMock
import pytest
from dotenv import load_dotenv
@@ -89,7 +88,7 @@ async def test_unified_function_calling_openai():
@pytest.mark.skipif(os.getenv("GOOGLE_API_KEY") is None, reason="GOOGLE_API_KEY is not set")
@pytest.mark.asyncio
async def test_unified_function_calling_gemini():
llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash-001")
llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"))
# This will fail if an exception is raised
await _test_llm_function_calling(llm)
@@ -97,8 +96,6 @@ async def test_unified_function_calling_gemini():
@pytest.mark.skipif(os.getenv("ANTHROPIC_API_KEY") is None, reason="ANTHROPIC_API_KEY is not set")
@pytest.mark.asyncio
async def test_unified_function_calling_anthropic():
llm = AnthropicLLMService(
api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-5-sonnet-20240620"
)
llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY"))
# This will fail if an exception is raised
await _test_llm_function_calling(llm)

View File

@@ -74,3 +74,7 @@ class TestGatedAggregator(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
if __name__ == "__main__":
unittest.main()

831
tests/test_aic_filter.py Normal file
View File

@@ -0,0 +1,831 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import time
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
# Check if aic_sdk is available
aic_sdk: Any
try:
import aic_sdk
HAS_AIC_SDK = True
except ImportError:
aic_sdk = None
HAS_AIC_SDK = False
# Module path for patching
AIC_FILTER_MODULE = "pipecat.audio.filters.aic_filter"
def _model_manager_ref_count(manager, key: str) -> int:
"""Test helper: return reference count for a cache key (reads internal cache)."""
with manager._lock:
entry = manager._cache.get(key)
return entry[1] if entry else 0
class MockProcessor:
"""A lightweight mock for AIC ProcessorAsync that mimics real behavior."""
def __init__(self):
self.processor_ctx = MockProcessorContext()
self.vad_ctx = MockVadContext()
def get_processor_context(self):
return self.processor_ctx
def get_vad_context(self):
return self.vad_ctx
async def process_async(self, audio_array):
# Return a copy of the input (simulating passthrough)
return audio_array.copy()
class MockProcessorContext:
"""A lightweight mock for AIC ProcessorContext."""
def __init__(self):
self.parameters_set: list[tuple] = []
self.reset_called = False
self._output_delay = 0
def get_output_delay(self):
return self._output_delay
def set_parameter(self, param, value):
self.parameters_set.append((param, value))
def reset(self):
self.reset_called = True
class UnsupportedEnhancementProcessorContext(MockProcessorContext):
"""Processor context mock that rejects EnhancementLevel updates."""
def __init__(self, enhancement_parameter, error_type):
super().__init__()
self._enhancement_parameter = enhancement_parameter
self._error_type = error_type
self.enhancement_attempts = 0
def set_parameter(self, param, value):
if param == self._enhancement_parameter:
self.enhancement_attempts += 1
raise self._error_type("EnhancementLevel out of range")
super().set_parameter(param, value)
class MockVadContext:
"""A lightweight mock for AIC VadContext."""
def __init__(self, speech_detected: bool = False):
self.speech_detected = speech_detected
self.parameters_set: list[tuple] = []
def is_speech_detected(self) -> bool:
return self.speech_detected
def set_parameter(self, param, value):
self.parameters_set.append((param, value))
class MockModel:
"""A lightweight mock for AIC Model."""
def __init__(self, model_id: str = "test-model"):
self._model_id = model_id
self._optimal_num_frames = 160
self._optimal_sample_rate = 16000
def get_optimal_num_frames(self, sample_rate: int):
"""Return optimal number of frames for the given sample rate."""
return self._optimal_num_frames
def get_id(self):
return self._model_id
def get_optimal_sample_rate(self):
return self._optimal_sample_rate
@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed")
class TestAICFilter(unittest.IsolatedAsyncioTestCase):
"""Test suite for AICFilter audio filter using real aic_sdk types."""
@classmethod
def setUpClass(cls):
"""Import AICFilter after confirming aic_sdk is available."""
from pipecat.audio.filters.aic_filter import AICFilter, AICModelManager
from pipecat.frames.frames import FilterEnableFrame
cls.AICFilter = AICFilter
cls.AICModelManager = AICModelManager
cls.FilterEnableFrame = FilterEnableFrame
def setUp(self):
"""Set up test fixtures before each test method."""
self.mock_model = MockModel()
self.mock_processor = MockProcessor()
def _create_filter_with_mocks(self, **kwargs):
"""Create an AICFilter with mocked SDK components."""
filter_kwargs = {
"license_key": "test-key",
"model_id": "test-model",
}
filter_kwargs.update(kwargs)
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
return self.AICFilter(**filter_kwargs)
async def _start_filter_with_mocks(self, filter_instance, sample_rate=16000):
"""Start a filter with mocked SDK components."""
cache_key = "test-cache-key"
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, cache_key))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(sample_rate)
async def test_initialization_requires_model_id_or_path(self):
"""Test filter initialization fails without model_id or model_path."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
with self.assertRaises(ValueError) as context:
self.AICFilter(license_key="test-key")
self.assertIn("model_id", str(context.exception))
self.assertIn("model_path", str(context.exception))
async def test_initialization_with_model_id(self):
"""Test filter initialization with model_id."""
filter_instance = self._create_filter_with_mocks()
self.assertEqual(filter_instance._license_key, "test-key")
self.assertEqual(filter_instance._model_id, "test-model")
self.assertIsNone(filter_instance._model_path)
self.assertIsNone(filter_instance._enhancement_level)
self.assertFalse(filter_instance._bypass)
async def test_initialization_with_model_path(self):
"""Test filter initialization with model_path."""
model_path = Path("/tmp/test.aicmodel")
filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path)
self.assertEqual(filter_instance._model_path, model_path)
self.assertIsNone(filter_instance._model_id)
async def test_initialization_with_custom_download_dir(self):
"""Test filter initialization with custom model_download_dir."""
download_dir = Path("/custom/cache")
filter_instance = self._create_filter_with_mocks(model_download_dir=download_dir)
self.assertEqual(filter_instance._model_download_dir, download_dir)
async def test_initialization_with_valid_enhancement_level(self):
"""Test filter initialization with a valid enhancement_level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.75)
self.assertEqual(filter_instance._enhancement_level, 0.75)
async def test_initialization_with_none_enhancement_level(self):
"""Test filter initialization with enhancement_level set to None."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
self.assertIsNone(filter_instance._enhancement_level)
async def test_initialization_invalid_enhancement_level_raises(self):
"""Test initialization rejects enhancement_level outside 0.0..1.0."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"):
with self.assertRaises(ValueError) as context:
self.AICFilter(
license_key="test-key",
model_id="test-model",
enhancement_level=1.5,
)
self.assertIn("enhancement_level", str(context.exception))
async def test_start_with_model_path(self):
"""Test starting filter with a local model path."""
model_path = Path("/tmp/test.aicmodel")
filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path)
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(
return_value=(self.mock_model, "path:/tmp/test.aicmodel")
)
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_manager_cls.acquire.assert_called_once()
call_kw = mock_manager_cls.acquire.call_args[1]
self.assertEqual(call_kw["model_path"], model_path)
self.assertIsNone(call_kw["model_id"])
self.assertTrue(filter_instance._aic_ready)
self.assertEqual(filter_instance._sample_rate, 16000)
self.assertEqual(filter_instance._frames_per_block, 160)
async def test_start_with_model_id_downloads(self):
"""Test starting filter with model_id uses manager (download happens in manager)."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor),
):
mock_manager_cls.acquire = AsyncMock(
return_value=(self.mock_model, "id:test-model:/custom/cache")
)
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_manager_cls.acquire.assert_called_once()
call_kw = mock_manager_cls.acquire.call_args[1]
self.assertEqual(call_kw["model_id"], "test-model")
self.assertTrue(filter_instance._aic_ready)
async def test_start_creates_processor(self):
"""Test that start creates processor with correct config."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(
f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor
) as mock_processor_cls,
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-cache-key"))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
mock_config_cls.optimal.assert_called_once()
mock_processor_cls.assert_called_once()
self.assertIsNotNone(filter_instance._processor_ctx)
self.assertIsNotNone(filter_instance._vad_ctx)
async def test_start_applies_initial_bypass_parameter(self):
"""Test that start applies bypass parameter."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertTrue(len(bypass_params) > 0)
self.assertEqual(bypass_params[-1][1], 0.0)
async def test_start_applies_enhancement_level_when_supported(self):
"""Test that start applies enhancement_level when supported by model."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertTrue(len(enhancement_params) > 0)
self.assertEqual(enhancement_params[-1][1], 0.65)
async def test_start_ignores_enhancement_level_when_unsupported(self):
"""Test unsupported enhancement_level logs warning and keeps filter ready."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.65)
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
unsupported_ctx = UnsupportedEnhancementProcessorContext(
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
)
self.mock_processor.processor_ctx = unsupported_ctx
await self._start_filter_with_mocks(filter_instance)
self.assertTrue(filter_instance._aic_ready)
self.assertIsNone(filter_instance._enhancement_level)
self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_start_does_not_set_enhancement_level_when_none(self):
"""Test start does not attempt enhancement_level when not configured."""
filter_instance = self._create_filter_with_mocks(enhancement_level=None)
with patch(f"{AIC_FILTER_MODULE}.logger.debug") as mock_debug:
await self._start_filter_with_mocks(filter_instance)
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
self.assertEqual(enhancement_params, [])
self.assertTrue(
any("default behavior" in str(call.args[0]) for call in mock_debug.call_args_list)
)
async def test_stop_cleans_up_resources(self):
"""Test that stop properly cleans up resources and releases model reference."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
cache_key = filter_instance._model_cache_key
with patch(f"{AIC_FILTER_MODULE}.AICModelManager.release") as mock_release:
await filter_instance.stop()
mock_release.assert_called_once_with(cache_key)
self.assertTrue(self.mock_processor.processor_ctx.reset_called)
self.assertIsNone(filter_instance._processor)
self.assertIsNone(filter_instance._processor_ctx)
self.assertIsNone(filter_instance._vad_ctx)
self.assertIsNone(filter_instance._model)
self.assertIsNone(filter_instance._model_cache_key)
self.assertFalse(filter_instance._aic_ready)
async def test_stop_without_start(self):
"""Test that stop can be called safely without start."""
filter_instance = self._create_filter_with_mocks()
# Should not raise
await filter_instance.stop()
async def test_model_manager_reference_count(self):
"""Test that AICModelManager reference count increments and decrements correctly."""
model_path = Path("/tmp/refcount-test.aicmodel")
mock_model = MockModel()
manager = self.AICModelManager
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.from_file.return_value = mock_model
# Acquire first reference
model1, key = await manager.acquire(model_path=model_path)
self.assertEqual(model1, mock_model)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
# Acquire second reference (same key, cached)
model2, key2 = await manager.acquire(model_path=model_path)
self.assertIs(model2, model1)
self.assertEqual(key2, key)
self.assertEqual(_model_manager_ref_count(manager, key), 2)
# Release one reference
manager.release(key)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
# Release last reference (model evicted from cache)
manager.release(key)
self.assertEqual(_model_manager_ref_count(manager, key), 0)
async def test_model_manager_concurrent_load_deduplication(self):
"""Test that concurrent acquire calls for the same key share a single load task."""
model_path = Path("/tmp/concurrent-load-test.aicmodel")
mock_model = MockModel()
manager = self.AICModelManager
load_count = 0
def from_file_once(path):
nonlocal load_count
load_count += 1
time.sleep(0.02) # yield so other acquire callers can hit _loading and await same task
return mock_model
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.from_file.side_effect = from_file_once
# Start several acquire calls concurrently before any completes
results = await asyncio.gather(
manager.acquire(model_path=model_path),
manager.acquire(model_path=model_path),
manager.acquire(model_path=model_path),
)
self.assertEqual(
load_count, 1, "Model.from_file should be called once for concurrent callers"
)
model1, key1 = results[0]
model2, key2 = results[1]
model3, key3 = results[2]
self.assertIs(model1, mock_model)
self.assertIs(model2, mock_model)
self.assertIs(model3, mock_model)
self.assertEqual(key1, key2)
self.assertEqual(key2, key3)
self.assertEqual(_model_manager_ref_count(manager, key1), 3)
# Release all references
manager.release(key1)
manager.release(key1)
manager.release(key1)
self.assertEqual(_model_manager_ref_count(manager, key1), 0)
async def test_load_model_from_file_invalid_args_raises(self):
"""Test _load_model_from_file defensive else: raises ValueError."""
manager = self.AICModelManager
with self.assertRaises(ValueError) as ctx:
await manager._load_model_from_file(
"key",
model_path=None,
model_id=None,
model_download_dir=None,
)
self.assertIn("Unexpected", str(ctx.exception))
async def test_model_manager_acquire_by_model_id_hits_download_path(self):
"""Test acquire with model_id runs download path in _load_model_from_file."""
model_id = "test-model-id"
model_download_dir = Path("/tmp/aic-downloads")
mock_model = MockModel()
manager = self.AICModelManager
with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls:
mock_model_cls.download_async = AsyncMock(
return_value="/tmp/aic-downloads/model.aicmodel"
)
mock_model_cls.from_file.return_value = mock_model
model, key = await manager.acquire(
model_id=model_id,
model_download_dir=model_download_dir,
)
mock_model_cls.download_async.assert_called_once()
mock_model_cls.from_file.assert_called_once_with("/tmp/aic-downloads/model.aicmodel")
self.assertIs(model, mock_model)
self.assertEqual(_model_manager_ref_count(manager, key), 1)
manager.release(key)
def test_get_cache_key_invalid_raises(self):
"""Test _get_cache_key raises ValueError for invalid args."""
with self.assertRaises(ValueError) as ctx:
self.AICModelManager._get_cache_key(model_path=None, model_id=None)
self.assertIn("model_path", str(ctx.exception))
with self.assertRaises(ValueError) as ctx2:
self.AICModelManager._get_cache_key(
model_path=None,
model_id="x",
model_download_dir=None,
)
self.assertIn("model_download_dir", str(ctx2.exception))
async def test_start_processor_init_failure(self):
"""Test start() when ProcessorAsync raises: exception logged, _aic_ready False."""
filter_instance = self._create_filter_with_mocks()
with (
patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls,
patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls,
patch(
f"{AIC_FILTER_MODULE}.ProcessorAsync",
side_effect=RuntimeError("SDK init failed"),
),
):
mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key"))
mock_config_cls.optimal.return_value = MagicMock()
await filter_instance.start(16000)
self.assertIsNone(filter_instance._processor)
self.assertFalse(filter_instance._aic_ready)
async def test_start_skips_unsupported_enhancement_level_after_first_attempt(self):
"""Test unsupported enhancement_level is attempted once and then skipped."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.9)
with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError):
unsupported_ctx = UnsupportedEnhancementProcessorContext(
aic_sdk.ProcessorParameter.EnhancementLevel, ValueError
)
self.mock_processor.processor_ctx = unsupported_ctx
await self._start_filter_with_mocks(filter_instance)
await filter_instance.stop()
await self._start_filter_with_mocks(filter_instance)
self.assertEqual(unsupported_ctx.enhancement_attempts, 1)
async def test_process_frame_disable_sets_bypass(self):
"""Test disable frame toggles bypass."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance)
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertTrue(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 1.0)
async def test_process_frame_enable_restores_configured_enhancement(self):
"""Test enable frame restores configured enhancement level."""
filter_instance = self._create_filter_with_mocks(enhancement_level=0.7)
await self._start_filter_with_mocks(filter_instance)
await filter_instance.process_frame(self.FilterEnableFrame(enable=False))
await filter_instance.process_frame(self.FilterEnableFrame(enable=True))
enhancement_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.EnhancementLevel
]
bypass_params = [
(p, v)
for p, v in self.mock_processor.processor_ctx.parameters_set
if p == aic_sdk.ProcessorParameter.Bypass
]
self.assertFalse(filter_instance._bypass)
self.assertEqual(enhancement_params[-1][1], 0.7)
self.assertEqual(bypass_params[-1][1], 0.0)
async def test_filter_when_not_ready(self):
"""Test that filter returns audio unchanged when not ready."""
filter_instance = self._create_filter_with_mocks()
# Don't call start()
input_audio = b"\x00\x01\x02\x03"
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(output_audio, input_audio)
async def test_filter_with_incomplete_frame(self):
"""Test filtering audio with incomplete frame data."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for less than one frame (100 samples = 200 bytes)
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
# Should return empty bytes since no complete frame
self.assertEqual(output_audio, b"")
async def test_filter_with_complete_frame(self):
"""Test filtering audio with exactly one complete frame."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for exactly one frame (160 samples = 320 bytes)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertIsInstance(output_audio, bytes)
self.assertEqual(len(output_audio), len(input_audio))
async def test_filter_with_multiple_frames(self):
"""Test filtering audio with multiple complete frames."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Create audio data for 3 complete frames (480 samples = 960 bytes)
samples = np.random.randint(-32768, 32767, size=480, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(len(output_audio), len(input_audio))
async def test_filter_with_buffering(self):
"""Test that filter properly buffers incomplete frames."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# First call: Send 100 samples (incomplete frame)
samples1 = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio1 = samples1.tobytes()
output_audio1 = await filter_instance.filter(input_audio1)
self.assertEqual(output_audio1, b"")
self.assertEqual(len(filter_instance._audio_buffer), 200)
# Second call: Send 60 more samples (now we have 160 total = 1 complete frame)
samples2 = np.random.randint(-32768, 32767, size=60, dtype=np.int16)
input_audio2 = samples2.tobytes()
output_audio2 = await filter_instance.filter(input_audio2)
self.assertEqual(len(output_audio2), 320)
self.assertEqual(len(filter_instance._audio_buffer), 0)
async def test_filter_with_partial_buffering(self):
"""Test that filter keeps remainder in buffer after processing."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Send 250 samples (1 complete frame + 90 samples remainder)
samples = np.random.randint(-32768, 32767, size=250, dtype=np.int16)
input_audio = samples.tobytes()
output_audio = await filter_instance.filter(input_audio)
self.assertEqual(len(output_audio), 320) # 1 frame
self.assertEqual(len(filter_instance._audio_buffer), 180) # 90 samples * 2 bytes
async def test_get_vad_context_before_start(self):
"""Test that get_vad_context raises before start."""
filter_instance = self._create_filter_with_mocks()
with self.assertRaises(RuntimeError) as context:
filter_instance.get_vad_context()
self.assertIn("not initialized", str(context.exception))
async def test_get_vad_context_after_start(self):
"""Test that get_vad_context returns context after start."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
vad_ctx = filter_instance.get_vad_context()
self.assertEqual(vad_ctx, self.mock_processor.vad_ctx)
async def test_create_vad_analyzer(self):
"""Test create_vad_analyzer returns analyzer with factory."""
filter_instance = self._create_filter_with_mocks()
analyzer = filter_instance.create_vad_analyzer()
self.assertIsNotNone(analyzer)
# Factory should be set
self.assertIsNotNone(analyzer._vad_context_factory)
async def test_create_vad_analyzer_with_params(self):
"""Test create_vad_analyzer with custom parameters."""
filter_instance = self._create_filter_with_mocks()
analyzer = filter_instance.create_vad_analyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
self.assertEqual(analyzer._pending_speech_hold_duration, 0.1)
self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05)
self.assertEqual(analyzer._pending_sensitivity, 8.0)
async def test_multiple_start_stop_cycles(self):
"""Test multiple start/stop cycles."""
filter_instance = self._create_filter_with_mocks()
for sample_rate in [16000, 24000, 48000]:
# Create fresh mock processor for each cycle
self.mock_processor = MockProcessor()
await self._start_filter_with_mocks(filter_instance, sample_rate)
self.assertTrue(filter_instance._aic_ready)
self.assertEqual(filter_instance._sample_rate, sample_rate)
await filter_instance.stop()
self.assertFalse(filter_instance._aic_ready)
async def test_concurrent_filter_calls(self):
"""Test that concurrent filter calls are handled safely."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def filter_audio():
return await filter_instance.filter(input_audio)
tasks = [filter_audio() for _ in range(10)]
results = await asyncio.gather(*tasks)
self.assertEqual(len(results), 10)
for result in results:
self.assertIsInstance(result, bytes)
async def test_concurrent_filter_no_buffer_resize_error(self):
"""Regression: concurrent filter() must not raise BufferError.
When process_async yields to the event loop, a second filter() call
runs and calls _audio_buffer.extend(). If the first call still holds
a memoryview on the bytearray, extend() raises:
BufferError: Existing exports of data: object cannot be re-sized
The fix snapshots the needed data into immutable bytes and trims the
buffer *before* any await, so no memoryview is held across yield
points.
"""
filter_instance = self._create_filter_with_mocks()
# Make process_async yield to the event loop so concurrent filter()
# calls can interleave and attempt _audio_buffer.extend().
async def yielding_process_async(audio_array):
await asyncio.sleep(0)
return audio_array.copy()
self.mock_processor.process_async = yielding_process_async
await self._start_filter_with_mocks(filter_instance)
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def filter_audio():
return await filter_instance.filter(input_audio)
# 20 concurrent calls to reliably trigger the interleaving.
tasks = [filter_audio() for _ in range(20)]
results = await asyncio.gather(*tasks)
for result in results:
self.assertIsInstance(result, bytes)
async def test_stop_during_filter_no_buffer_resize_error(self):
"""Regression: stop() during filter() must not raise BufferError.
When filter() holds a memoryview on _audio_buffer across an await
(process_async), a concurrent stop() that calls
_audio_buffer.clear() raises:
BufferError: Existing exports of data: object cannot be re-sized
The fix removes the memoryview by snapshotting data into immutable
bytes before any await.
"""
filter_instance = self._create_filter_with_mocks()
# Gate so stop() waits until filter() is inside process_async.
processing_started = asyncio.Event()
async def yielding_process_async(audio_array):
processing_started.set()
await asyncio.sleep(0) # yield — stop() runs here
return audio_array.copy()
self.mock_processor.process_async = yielding_process_async
await self._start_filter_with_mocks(filter_instance)
# Exactly one complete frame so the loop runs once.
samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16)
input_audio = samples.tobytes()
async def stop_after_filter_enters_process_async():
await processing_started.wait()
await filter_instance.stop()
# filter() enters process_async → yields → stop() calls clear()
filter_result, _ = await asyncio.gather(
filter_instance.filter(input_audio),
stop_after_filter_enters_process_async(),
)
self.assertIsInstance(filter_result, bytes)
async def test_buffer_cleared_on_stop(self):
"""Test that audio buffer is cleared when stopping."""
filter_instance = self._create_filter_with_mocks()
await self._start_filter_with_mocks(filter_instance)
# Add incomplete frame to buffer
samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16)
input_audio = samples.tobytes()
await filter_instance.filter(input_audio)
# Verify buffer has data
self.assertGreater(len(filter_instance._audio_buffer), 0)
# Stop should clear buffer
await filter_instance.stop()
self.assertEqual(len(filter_instance._audio_buffer), 0)
async def test_set_sdk_id_called_on_init(self):
"""Test that set_sdk_id is called during initialization."""
with patch(f"{AIC_FILTER_MODULE}.set_sdk_id") as mock_set_sdk_id:
self.AICFilter(license_key="test-key", model_id="test-model")
mock_set_sdk_id.assert_called_once_with(6)
if __name__ == "__main__":
unittest.main()

322
tests/test_aic_vad.py Normal file
View File

@@ -0,0 +1,322 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
# Check if aic_sdk is available
try:
import aic_sdk
HAS_AIC_SDK = True
except ImportError:
HAS_AIC_SDK = False
@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed")
class TestAICVADAnalyzer(unittest.IsolatedAsyncioTestCase):
"""Test suite for AICVADAnalyzer using real aic_sdk."""
@classmethod
def setUpClass(cls):
"""Import AICVADAnalyzer after confirming aic_sdk is available."""
from pipecat.audio.vad.aic_vad import AICVADAnalyzer
cls.AICVADAnalyzer = AICVADAnalyzer
def test_initialization_without_factory(self):
"""Test analyzer initialization without a factory."""
analyzer = self.AICVADAnalyzer()
self.assertIsNone(analyzer._vad_context_factory)
self.assertIsNone(analyzer._vad_ctx)
# Fixed params should be set
self.assertEqual(analyzer._params.confidence, 0.5)
self.assertEqual(analyzer._params.start_secs, 0.0)
self.assertEqual(analyzer._params.stop_secs, 0.0)
self.assertEqual(analyzer._params.min_volume, 0.0)
def test_initialization_with_factory(self):
"""Test analyzer initialization with a factory."""
# Create a mock VAD context for testing
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
self.assertIsNotNone(analyzer._vad_context_factory)
def test_initialization_with_vad_params(self):
"""Test analyzer initialization with VAD parameters."""
analyzer = self.AICVADAnalyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
self.assertEqual(analyzer._pending_speech_hold_duration, 0.1)
self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05)
self.assertEqual(analyzer._pending_sensitivity, 8.0)
def test_bind_vad_context_factory(self):
"""Test binding a factory post-construction."""
mock_vad_ctx = MockVadContext()
analyzer = self.AICVADAnalyzer()
factory = lambda: mock_vad_ctx
analyzer.bind_vad_context_factory(factory)
self.assertEqual(analyzer._vad_context_factory, factory)
# Should have attempted to initialize
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_bind_vad_context_factory_applies_params(self):
"""Test that binding factory applies pending VAD params."""
mock_vad_ctx = MockVadContext()
analyzer = self.AICVADAnalyzer(
speech_hold_duration=0.1,
minimum_speech_duration=0.05,
sensitivity=8.0,
)
factory = lambda: mock_vad_ctx
analyzer.bind_vad_context_factory(factory)
# Verify parameters were applied
self.assertIn(
(aic_sdk.VadParameter.SpeechHoldDuration, 0.1),
mock_vad_ctx.parameters_set,
)
self.assertIn(
(aic_sdk.VadParameter.MinimumSpeechDuration, 0.05),
mock_vad_ctx.parameters_set,
)
self.assertIn(
(aic_sdk.VadParameter.Sensitivity, 8.0),
mock_vad_ctx.parameters_set,
)
def test_set_sample_rate(self):
"""Test setting sample rate."""
analyzer = self.AICVADAnalyzer()
analyzer.set_sample_rate(16000)
self.assertEqual(analyzer._sample_rate, 16000)
def test_set_sample_rate_with_init_sample_rate(self):
"""Test that init_sample_rate takes precedence."""
# Create analyzer and manually set _init_sample_rate
analyzer = self.AICVADAnalyzer()
analyzer._init_sample_rate = 48000
analyzer.set_sample_rate(16000)
# init_sample_rate should take precedence
self.assertEqual(analyzer._sample_rate, 48000)
def test_set_sample_rate_triggers_context_init(self):
"""Test that set_sample_rate attempts context initialization."""
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_num_frames_required_with_sample_rate(self):
"""Test num_frames_required returns correct value."""
analyzer = self.AICVADAnalyzer()
analyzer.set_sample_rate(16000)
frames = analyzer.num_frames_required()
# 10ms at 16kHz = 160 frames
self.assertEqual(frames, 160)
def test_num_frames_required_different_sample_rates(self):
"""Test num_frames_required for different sample rates."""
analyzer = self.AICVADAnalyzer()
test_cases = [
(8000, 80), # 10ms at 8kHz
(16000, 160), # 10ms at 16kHz
(24000, 240), # 10ms at 24kHz
(48000, 480), # 10ms at 48kHz
]
for sample_rate, expected_frames in test_cases:
analyzer.set_sample_rate(sample_rate)
frames = analyzer.num_frames_required()
self.assertEqual(frames, expected_frames, f"Failed for {sample_rate}Hz")
def test_num_frames_required_no_sample_rate(self):
"""Test num_frames_required returns default when no sample rate."""
analyzer = self.AICVADAnalyzer()
frames = analyzer.num_frames_required()
# Default is 160
self.assertEqual(frames, 160)
def test_voice_confidence_no_context(self):
"""Test voice_confidence returns 0.0 when no context."""
analyzer = self.AICVADAnalyzer()
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_voice_confidence_speech_detected(self):
"""Test voice_confidence returns 1.0 when speech detected."""
mock_vad_ctx = MockVadContext(speech_detected=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 1.0)
def test_voice_confidence_no_speech(self):
"""Test voice_confidence returns 0.0 when no speech."""
mock_vad_ctx = MockVadContext(speech_detected=False)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_voice_confidence_handles_exception(self):
"""Test voice_confidence handles exceptions gracefully."""
mock_vad_ctx = MockVadContext(raise_on_detect=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=factory)
analyzer.set_sample_rate(16000)
confidence = analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(confidence, 0.0)
def test_lazy_initialization(self):
"""Test that VAD context is lazily initialized."""
call_count = 0
mock_vad_ctx = MockVadContext()
def counting_factory():
nonlocal call_count
call_count += 1
return mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=counting_factory)
# Factory not called yet
self.assertEqual(call_count, 0)
# First call to voice_confidence triggers initialization
analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(call_count, 1)
# Subsequent calls don't re-initialize
analyzer.voice_confidence(b"\x00" * 320)
analyzer.voice_confidence(b"\x00" * 320)
self.assertEqual(call_count, 1)
def test_deferred_initialization_on_factory_failure(self):
"""Test that initialization is deferred when factory fails."""
call_count = 0
mock_vad_ctx = MockVadContext(speech_detected=True)
def failing_then_succeeding_factory():
nonlocal call_count
call_count += 1
if call_count < 3:
raise RuntimeError("Not ready yet")
return mock_vad_ctx
analyzer = self.AICVADAnalyzer(vad_context_factory=failing_then_succeeding_factory)
# First two calls fail, should return 0.0
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0)
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0)
# Third call succeeds
self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 1.0)
def test_apply_vad_params_deferred_on_failure(self):
"""Test that VAD param application handles exceptions."""
mock_vad_ctx = MockVadContext(raise_on_set_param=True)
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(
vad_context_factory=factory,
speech_hold_duration=0.1,
)
# Should not raise, just log debug message
analyzer.bind_vad_context_factory(factory)
# Context should still be set despite param failure
self.assertEqual(analyzer._vad_ctx, mock_vad_ctx)
def test_apply_vad_params_only_set_values(self):
"""Test that only specified VAD params are applied."""
mock_vad_ctx = MockVadContext()
factory = lambda: mock_vad_ctx
analyzer = self.AICVADAnalyzer(
vad_context_factory=factory,
speech_hold_duration=0.1,
# minimum_speech_duration and sensitivity not set
)
analyzer.bind_vad_context_factory(factory)
# Only SpeechHoldDuration should be set
self.assertEqual(len(mock_vad_ctx.parameters_set), 1)
self.assertIn(
(aic_sdk.VadParameter.SpeechHoldDuration, 0.1),
mock_vad_ctx.parameters_set,
)
def test_fixed_vad_params(self):
"""Test that VAD uses fixed parameters."""
analyzer = self.AICVADAnalyzer()
# These are the fixed params for AIC VAD
self.assertEqual(analyzer._params.confidence, 0.5)
self.assertEqual(analyzer._params.start_secs, 0.0)
self.assertEqual(analyzer._params.stop_secs, 0.0)
self.assertEqual(analyzer._params.min_volume, 0.0)
class MockVadContext:
"""A lightweight mock for AIC VadContext that mimics real behavior."""
def __init__(
self,
speech_detected: bool = False,
raise_on_detect: bool = False,
raise_on_set_param: bool = False,
):
self.speech_detected = speech_detected
self.raise_on_detect = raise_on_detect
self.raise_on_set_param = raise_on_set_param
self.parameters_set: list[tuple] = []
def is_speech_detected(self) -> bool:
if self.raise_on_detect:
raise RuntimeError("VAD error")
return self.speech_detected
def set_parameter(self, param, value):
if self.raise_on_set_param:
raise RuntimeError("Param error")
self.parameters_set.append((param, value))
if __name__ == "__main__":
unittest.main()

View File

@@ -115,3 +115,7 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase):
self.assertEqual(merged_audio[6:8], bot_audio[2:4])
self.assertEqual(len(self.processor._user_audio_buffer), 0)
self.assertEqual(len(self.processor._bot_audio_buffer), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
FunctionCallResultProperties,
InterimTranscriptionFrame,
InterruptionFrame,
InterruptionTaskFrame,
LLMContextAssistantTimestampFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
@@ -567,7 +566,7 @@ class BaseTestUserContextAggregator:
SleepFrame(),
UserStoppedSpeakingFrame(),
]
expected_up_frames = [InterruptionTaskFrame]
expected_up_frames = [InterruptionFrame]
expected_down_frames = [
BotStartedSpeakingFrame,
UserStartedSpeakingFrame,
@@ -1055,3 +1054,7 @@ class TestLLMAssistantAggregator(
0,
"Hello Pipecat. Here's some code: ```python\nprint('Hello, World!')\n``` ```javascript\nconsole.log('Hello, World!');\n``` And some more: ```html\n<div>Hello, World!</div>\n``` Hope that helps!",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -12,6 +12,7 @@ from pipecat.frames.frames import (
FunctionCallFromLLM,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InterimTranscriptionFrame,
InterruptionFrame,
LLMContextAssistantTimestampFrame,
LLMContextFrame,
@@ -24,7 +25,10 @@ from pipecat.frames.frames import (
LLMThoughtEndFrame,
LLMThoughtStartFrame,
LLMThoughtTextFrame,
StartFrame,
TranscriptionFrame,
TranslationFrame,
UserMuteStartedFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
@@ -40,8 +44,12 @@ from pipecat.processors.aggregators.llm_response_universal import (
LLMUserAggregatorParams,
)
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy
from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy
from pipecat.turns.user_mute import (
FirstSpeechUserMuteStrategy,
FunctionCallUserMuteStrategy,
MuteUntilFirstBotCompleteUserMuteStrategy,
)
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
USER_TURN_STOP_TIMEOUT = 0.2
@@ -147,9 +155,38 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
)
assert context.messages[0]["content"] == "Hi there!"
async def test_llm_messages_update_does_not_inject_turn_completion_into_context(self):
context = LLMContext()
params = LLMUserAggregatorParams(filter_incomplete_user_turns=True)
pipeline = Pipeline([LLMUserAggregator(context, params=params)])
new_messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
]
frames_to_send = [LLMMessagesUpdateFrame(messages=new_messages)]
await run_test(
pipeline,
frames_to_send=frames_to_send,
)
# Turn completion instructions are now set via system_instruction on the
# LLM service, not injected into context messages.
assert len(context.messages) == 2
assert context.messages[0]["content"] == "You are a helpful assistant."
assert context.messages[1]["content"] == "Hello!"
async def test_default_user_turn_strategies(self):
context = LLMContext()
user_aggregator = LLMUserAggregator(context)
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
],
),
),
)
should_start = None
should_stop = None
@@ -173,6 +210,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
]
expected_down_frames = [
VADUserStartedSpeakingFrame,
@@ -241,7 +280,9 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
context,
params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(
stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)],
stop=[
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
),
@@ -270,13 +311,13 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
pipeline = Pipeline([user_aggregator])
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
frames_to_send = [
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(
pipeline,
@@ -344,6 +385,109 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
# The user mute strategies should have muted the user.
self.assertFalse(user_turn)
async def test_pending_transcription_emitted_on_end_frame(self):
"""Pending user transcription should be emitted when EndFrame arrives."""
context = LLMContext()
user_aggregator = LLMUserAggregator(context)
stop_messages = []
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
stop_messages.append((strategy, message))
pipeline = Pipeline([user_aggregator])
# Start turn and send transcription, but don't trigger normal turn stop
frames_to_send = [
VADUserStartedSpeakingFrame(),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
# No VADUserStoppedSpeakingFrame - turn doesn't stop normally
# EndFrame will be sent by run_test, triggering emission
]
await run_test(pipeline, frames_to_send=frames_to_send)
# The pending transcription should be emitted on EndFrame
self.assertEqual(len(stop_messages), 1)
strategy, message = stop_messages[0]
self.assertIsNone(strategy) # strategy is None for end/cancel
self.assertEqual(message.content, "Hello!")
async def test_start_frame_before_mute_event(self):
"""StartFrame must reach downstream before mute events are broadcast.
With MuteUntilFirstBotCompleteUserMuteStrategy, the mute logic should
not run on control frames (StartFrame, EndFrame, CancelFrame). This
ensures StartFrame reaches downstream processors before
UserMuteStartedFrame is broadcast.
The default TurnAnalyzerUserTurnStopStrategy broadcasts a
SpeechControlParamsFrame when it processes StartFrame, which gets
re-queued to the aggregator. That non-control frame legitimately
triggers the mute state change, so UserMuteStartedFrame follows
StartFrame — but crucially, after it.
"""
context = LLMContext()
user_aggregator = LLMUserAggregator(
context,
params=LLMUserAggregatorParams(
user_mute_strategies=[MuteUntilFirstBotCompleteUserMuteStrategy()],
),
)
pipeline = Pipeline([user_aggregator])
# run_test internally sends StartFrame via PipelineRunner. With
# ignore_start=False we can verify ordering: StartFrame must arrive
# before UserMuteStartedFrame. Before the fix, UserMuteStartedFrame
# was broadcast before StartFrame reached downstream processors.
(down_frames, _) = await run_test(
pipeline,
frames_to_send=[],
expected_down_frames=[StartFrame, UserMuteStartedFrame],
ignore_start=False,
)
async def test_interim_transcription_not_pushed_downstream(self):
"""InterimTranscriptionFrame should be consumed and not pushed downstream."""
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
InterimTranscriptionFrame(text="Hel", user_id="", timestamp="now"),
InterimTranscriptionFrame(text="Hello", user_id="", timestamp="now"),
]
# The interim transcription triggers a user turn start via the default
# TranscriptionUserTurnStartStrategy, so we expect turn-related frames
# but NOT the InterimTranscriptionFrame itself.
expected_down_frames = [
UserStartedSpeakingFrame,
InterruptionFrame,
]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertFalse(any(isinstance(f, InterimTranscriptionFrame) for f in down_frames))
async def test_translation_not_pushed_downstream(self):
"""TranslationFrame should be consumed and not pushed downstream."""
context = LLMContext()
pipeline = Pipeline([LLMUserAggregator(context)])
frames_to_send = [
TranslationFrame(text="Hola!", user_id="", timestamp="now", language="es"),
]
# No downstream frames expected — translations are consumed.
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=[],
)
class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase):
async def test_empty(self):
@@ -512,3 +656,80 @@ class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase):
]
await run_test(aggregator, frames_to_send=frames_to_send)
self.assertEqual(thought_message.content, "I'm thinking!")
async def test_pending_text_emitted_on_end_frame(self):
"""Pending assistant text should be emitted when EndFrame arrives."""
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
stop_messages = []
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
# Start response and send text, but don't send LLMFullResponseEndFrame
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame("Hello from Pipecat!"),
# No LLMFullResponseEndFrame - response doesn't end normally
# EndFrame will be sent by run_test, triggering emission
]
await run_test(aggregator, frames_to_send=frames_to_send)
# The pending text should be emitted on EndFrame
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
async def test_turn_completion_markers_stripped_from_transcript(self):
"""Turn completion markers should be stripped from assistant transcript."""
from pipecat.turns.user_turn_completion_mixin import (
USER_TURN_COMPLETE_MARKER,
USER_TURN_INCOMPLETE_SHORT_MARKER,
)
context = LLMContext()
aggregator = LLMAssistantAggregator(context)
stop_messages = []
@aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
# Send text with a turn completion marker
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame(f"{USER_TURN_COMPLETE_MARKER} Hello from Pipecat!"),
LLMFullResponseEndFrame(),
]
await run_test(aggregator, frames_to_send=frames_to_send)
# The marker should be stripped from the transcript
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
# Test incomplete markers are also stripped
stop_messages.clear()
context2 = LLMContext()
aggregator2 = LLMAssistantAggregator(context2)
@aggregator2.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped2(aggregator, message: AssistantTurnStoppedMessage):
stop_messages.append(message)
frames_to_send = [
LLMFullResponseStartFrame(),
LLMTextFrame(USER_TURN_INCOMPLETE_SHORT_MARKER),
LLMFullResponseEndFrame(),
]
await run_test(aggregator2, frames_to_send=frames_to_send)
# The incomplete marker should be stripped (resulting in empty content)
self.assertEqual(len(stop_messages), 1)
self.assertEqual(stop_messages[0].content, "")
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -90,3 +90,7 @@ class TestDailyTransport(unittest.IsolatedAsyncioTestCase):
camera.write_frame.assert_called_with(b"test")
mic.write_frames.assert_called()
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import io
import pytest
from loguru import logger
from pipecat.services.deepgram.stt import _derive_deepgram_urls
@pytest.mark.parametrize(
"base_url, expected_ws, expected_http",
[
# Secure schemes
("wss://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
("https://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
# Insecure schemes (air-gapped deployments)
("ws://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"),
("http://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"),
# Bare hostname defaults to secure
("mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"),
# With port
("ws://localhost:8080", "ws://localhost:8080", "http://localhost:8080"),
("wss://localhost:443", "wss://localhost:443", "https://localhost:443"),
("localhost:8080", "wss://localhost:8080", "https://localhost:8080"),
# With path
("wss://host/v1/listen", "wss://host/v1/listen", "https://host/v1/listen"),
("http://host/v1/listen", "ws://host/v1/listen", "http://host/v1/listen"),
],
)
def test_derive_deepgram_urls(base_url, expected_ws, expected_http):
ws_url, http_url = _derive_deepgram_urls(base_url)
assert ws_url == expected_ws
assert http_url == expected_http
def test_derive_deepgram_urls_unknown_scheme_warns():
sink = io.StringIO()
handler_id = logger.add(sink, format="{message}")
try:
ws_url, http_url = _derive_deepgram_urls("ftp://mydeepgram.com")
# Falls back to secure
assert ws_url == "wss://mydeepgram.com"
assert http_url == "https://mydeepgram.com"
assert "Unrecognized scheme" in sink.getvalue()
finally:
logger.remove(handler_id)

View File

@@ -240,3 +240,7 @@ class TestDTMFAggregator(unittest.IsolatedAsyncioTestCase):
]
self.assertEqual(len(transcription_frames), 1)
self.assertEqual(transcription_frames[0].text, "DTMF: 0123456789*#")
if __name__ == "__main__":
unittest.main()

View File

@@ -14,10 +14,12 @@ from pipecat.frames.frames import (
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.filters.frame_filter import FrameFilter
from pipecat.processors.filters.function_filter import FunctionFilter
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@@ -93,6 +95,98 @@ class TestFunctionFilter(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
async def test_no_direction_filters_both_directions(self):
"""When direction is None, frames in both directions are filtered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=None: filter applies in both directions. The downstream
# TextFrame is blocked and the upstream TextFrame pushed by
# UpstreamPusher is also blocked.
filter = FunctionFilter(filter=block_text, direction=None)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [
TextFrame(text="Hello!"),
UserStartedSpeakingFrame(),
]
expected_down_frames = [UserStartedSpeakingFrame]
expected_up_frames = []
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
async def test_downstream_direction_passes_upstream(self):
"""When direction is DOWNSTREAM, upstream frames pass through unfiltered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=DOWNSTREAM: filter only applies downstream, so the
# upstream TextFrame pushed by UpstreamPusher passes through.
filter = FunctionFilter(filter=block_text)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [UserStartedSpeakingFrame()]
expected_down_frames = [UserStartedSpeakingFrame]
expected_up_frames = [TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
async def test_upstream_direction_passes_downstream(self):
"""When direction is UPSTREAM, downstream frames pass through unfiltered."""
class UpstreamPusher(FrameProcessor):
"""Pushes a TextFrame upstream when it receives a system frame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, UserStartedSpeakingFrame):
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
async def block_text(frame: Frame):
return not isinstance(frame, TextFrame)
# direction=UPSTREAM: filter only applies upstream, so the
# downstream TextFrame passes through but the upstream TextFrame
# pushed by UpstreamPusher is blocked.
filter = FunctionFilter(filter=block_text, direction=FrameDirection.UPSTREAM)
pipeline = Pipeline([filter, UpstreamPusher()])
frames_to_send = [TextFrame(text="Hello!"), UserStartedSpeakingFrame()]
expected_down_frames = [UserStartedSpeakingFrame, TextFrame]
expected_up_frames = []
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
async def test_no_wake_word(self):
@@ -118,3 +212,7 @@ class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
assert received_down[-1].text == "Phrase 1"
if __name__ == "__main__":
unittest.main()

View File

@@ -6,7 +6,8 @@
import asyncio
import unittest
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List
from pipecat.frames.frames import (
DataFrame,
@@ -14,16 +15,29 @@ from pipecat.frames.frames import (
Frame,
InterruptionFrame,
OutputTransportMessageUrgentFrame,
StopFrame,
SystemFrame,
TextFrame,
UninterruptibleFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.processors.frame_processor import (
FrameDirection,
FrameProcessor,
)
from pipecat.tests.utils import SleepFrame, run_test
@dataclass
class BroadcastTestFrame(DataFrame):
"""Test frame with init fields for broadcast testing."""
text: str = ""
value: int = 0
items: List[str] = field(default_factory=list)
class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
async def test_before_after_events(self):
identity = IdentityFilter()
@@ -69,50 +83,38 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
assert before_push_called
assert after_push_called
async def test_interruption_and_wait(self):
class DelayFrameProcessor(FrameProcessor):
"""This processors just gives time to the event loop to change
between tasks. Otherwise things happen to fast."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await asyncio.sleep(0.1)
await self.push_frame(frame, direction)
async def test_broadcast_interruption(self):
"""Test that broadcast_interruption() pushes InterruptionFrame both
directions and allows subsequent code to run."""
class InterruptFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.push_interruption_task_frame_and_wait()
await self.broadcast_interruption()
await self.push_frame(OutputTransportMessageUrgentFrame(message=frame.text))
else:
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayFrameProcessor(), InterruptFrameProcessor()])
pipeline = Pipeline([InterruptFrameProcessor()])
frames_to_send = [
# Just a random interruption to make sure we don't clear anything
# before the actual `InterruptionTaskFrame` interruption.
InterruptionFrame(),
# This will generate an `InterruptionTaskFrame` and will wait for an
# `InterruptionFrame`.
TextFrame(text="Hello from Pipecat!"),
# Just give time for everything to complete.
SleepFrame(sleep=0.5),
EndFrame(),
]
expected_down_frames = [
InterruptionFrame,
InterruptionFrame,
OutputTransportMessageUrgentFrame,
EndFrame,
]
expected_up_frames = [
InterruptionFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
send_end_frame=False,
expected_up_frames=expected_up_frames,
)
async def test_interruptible_frames(self):
@@ -186,3 +188,292 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_broadcast_frame(self):
"""Test that broadcast_frame creates two separate frames with fresh IDs."""
downstream_frames: List[Frame] = []
upstream_frames: List[Frame] = []
class BroadcastTestProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.broadcast_frame(
BroadcastTestFrame, text="hello", value=42, items=["a", "b"]
)
else:
await self.push_frame(frame, direction)
class CaptureProcessor(FrameProcessor):
def __init__(self, capture_list: List[Frame], direction: FrameDirection):
super().__init__()
self._capture_list = capture_list
self._capture_direction = direction
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame):
self._capture_list.append(frame)
await self.push_frame(frame, direction)
up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM)
broadcaster = BroadcastTestProcessor()
down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM)
pipeline = Pipeline([up_capture, broadcaster, down_capture])
frames_to_send = [TextFrame(text="trigger")]
expected_down_frames = [BroadcastTestFrame]
expected_up_frames = [BroadcastTestFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
# Verify we got one frame in each direction
self.assertEqual(len(downstream_frames), 1)
self.assertEqual(len(upstream_frames), 1)
down_frame = downstream_frames[0]
up_frame = upstream_frames[0]
# Verify the frames have different IDs (they are separate instances)
self.assertNotEqual(down_frame.id, up_frame.id)
# Verify the frames have the correct field values
self.assertEqual(down_frame.text, "hello")
self.assertEqual(down_frame.value, 42)
self.assertEqual(down_frame.items, ["a", "b"])
self.assertEqual(up_frame.text, "hello")
self.assertEqual(up_frame.value, 42)
self.assertEqual(up_frame.items, ["a", "b"])
# Verify the items lists are shared references (no deep copy)
self.assertIs(down_frame.items, up_frame.items)
async def test_broadcast_frame_instance(self):
"""Test that broadcast_frame_instance shallow-copies all fields except id and name."""
downstream_frames: List[Frame] = []
upstream_frames: List[Frame] = []
original_frame: List[Frame] = []
class BroadcastInstanceTestProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BroadcastTestFrame):
# Set some non-init fields on the frame
frame.pts = 12345
frame.metadata = {"key": "value", "nested": {"a": 1}}
original_frame.append(frame)
await self.broadcast_frame_instance(frame)
else:
await self.push_frame(frame, direction)
class CaptureProcessor(FrameProcessor):
def __init__(self, capture_list: List[Frame], direction: FrameDirection):
super().__init__()
self._capture_list = capture_list
self._capture_direction = direction
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame):
self._capture_list.append(frame)
await self.push_frame(frame, direction)
up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM)
broadcaster = BroadcastInstanceTestProcessor()
down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM)
pipeline = Pipeline([up_capture, broadcaster, down_capture])
# Create a frame with mutable fields to test shallow copying
test_frame = BroadcastTestFrame(text="test", value=99, items=["x", "y", "z"])
frames_to_send = [test_frame]
expected_down_frames = [BroadcastTestFrame]
expected_up_frames = [BroadcastTestFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
# Verify we got one frame in each direction
self.assertEqual(len(downstream_frames), 1)
self.assertEqual(len(upstream_frames), 1)
self.assertEqual(len(original_frame), 1)
orig = original_frame[0]
down_frame = downstream_frames[0]
up_frame = upstream_frames[0]
# Verify the frames have different IDs and names (fresh values)
self.assertNotEqual(down_frame.id, orig.id)
self.assertNotEqual(up_frame.id, orig.id)
self.assertNotEqual(down_frame.id, up_frame.id)
self.assertNotEqual(down_frame.name, orig.name)
self.assertNotEqual(up_frame.name, orig.name)
# Verify init fields are copied correctly
self.assertEqual(down_frame.text, "test")
self.assertEqual(down_frame.value, 99)
self.assertEqual(down_frame.items, ["x", "y", "z"])
self.assertEqual(up_frame.text, "test")
self.assertEqual(up_frame.value, 99)
self.assertEqual(up_frame.items, ["x", "y", "z"])
# Verify non-init fields (except id/name) are copied
self.assertEqual(down_frame.pts, 12345)
self.assertEqual(down_frame.metadata, {"key": "value", "nested": {"a": 1}})
self.assertEqual(up_frame.pts, 12345)
self.assertEqual(up_frame.metadata, {"key": "value", "nested": {"a": 1}})
# Verify mutable fields are shallow-copied (shared references)
self.assertIs(down_frame.items, orig.items)
self.assertIs(up_frame.items, orig.items)
self.assertIs(down_frame.metadata, orig.metadata)
self.assertIs(up_frame.metadata, orig.metadata)
async def test_terminal_frames_survive_interruption(self):
"""Test that EndFrame survives interruption (it is uninterruptible).
This test simulates issue #3524 where an InterruptionFrame during slow
processing would cause terminal frames to be lost, freezing the pipeline.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption.
When processing a TextFrame, it sleeps and then pushes an
InterruptionFrame to simulate what happens when interruption occurs
while a terminal frame is in the queue.
"""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow EndFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the EndFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
# Verify EndFrame was received by our capture processor (survived interruption)
# Note: run_test filters EndFrame from expected_down_frames when send_end_frame=True,
# but our capture processor sees it before that filtering.
end_frames = [f for f in received_frames if isinstance(f, EndFrame)]
self.assertEqual(len(end_frames), 1, "EndFrame should survive interruption")
async def test_stop_frame_survives_interruption(self):
"""Test that StopFrame survives interruption (it is uninterruptible).
Similar to test_terminal_frames_survive_interruption but specifically
for StopFrame.
"""
received_frames: List[Frame] = []
class DelayAndInterruptProcessor(FrameProcessor):
"""This processor delays processing and then generates an interruption."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
# Delay to allow StopFrame to be queued
await asyncio.sleep(0.1)
# Push interruption - this should NOT discard the StopFrame
await self.push_frame(InterruptionFrame(), direction)
await self.push_frame(frame, direction)
class CaptureFrameProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
received_frames.append(frame)
await self.push_frame(frame, direction)
pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
StopFrame(),
]
expected_down_frames = [
InterruptionFrame,
TextFrame,
StopFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
send_end_frame=False,
)
# Verify StopFrame was received (survived interruption)
stop_frames = [f for f in received_frames if isinstance(f, StopFrame)]
self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption")
async def test_broadcast_interruption_allows_subsequent_code(self):
"""Test that broadcast_interruption() returns immediately, allowing the
caller to run code afterwards (e.g. push an urgent frame)."""
code_after_ran = False
class InterruptOnTextProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
nonlocal code_after_ran
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
await self.broadcast_interruption()
code_after_ran = True
await self.push_frame(OutputTransportMessageUrgentFrame(message="done"))
else:
await self.push_frame(frame, direction)
pipeline = Pipeline([InterruptOnTextProcessor()])
frames_to_send = [
TextFrame(text="trigger"),
]
expected_down_frames = [
InterruptionFrame,
OutputTransportMessageUrgentFrame,
]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
self.assertTrue(code_after_ran, "Code after broadcast_interruption() should execute")
if __name__ == "__main__":
unittest.main()

View File

@@ -204,3 +204,7 @@ class TestFunctionAdapters(unittest.TestCase):
}
]
assert AWSBedrockLLMAdapter().to_provider_tools_format(self.tools_def) == expected
if __name__ == "__main__":
unittest.main()

View File

@@ -43,15 +43,14 @@ For AWS Bedrock adapter:
import unittest
from google.genai.types import Content, Part
from openai.types.chat import ChatCompletionMessage
from pipecat.adapters.services.anthropic_adapter import AnthropicLLMAdapter
from pipecat.adapters.services.bedrock_adapter import AWSBedrockLLMAdapter
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter
from pipecat.processors.aggregators.llm_context import (
LLMContext,
LLMSpecificMessage,
LLMStandardMessage,
)
@@ -994,5 +993,222 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(len(params["messages"]), 0)
class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
def setUp(self) -> None:
"""Sets up a common adapter instance for all tests."""
self.adapter = PerplexityLLMAdapter()
def test_standard_messages_pass_through(self):
"""Test that a valid [user, assistant, user] sequence passes through unchanged."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "Hello")
self.assertEqual(params["messages"][1]["role"], "assistant")
self.assertEqual(params["messages"][1]["content"], "Hi there!")
self.assertEqual(params["messages"][2]["role"], "user")
self.assertEqual(params["messages"][2]["content"], "How are you?")
def test_initial_system_message_preserved(self):
"""Test that a valid [system, user, assistant, user] sequence passes through unchanged."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
{"role": "user", "content": "Bye"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 4)
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
self.assertEqual(params["messages"][1]["role"], "user")
self.assertEqual(params["messages"][2]["role"], "assistant")
self.assertEqual(params["messages"][3]["role"], "user")
def test_consecutive_same_role_messages_merged(self):
"""Test that consecutive user messages are merged into list-of-dicts content."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "First message"},
{"role": "user", "content": "Second message"},
{"role": "assistant", "content": "Response"},
{"role": "user", "content": "Third message"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 3)
# First message should be merged users
merged = params["messages"][0]
self.assertEqual(merged["role"], "user")
self.assertIsInstance(merged["content"], list)
self.assertEqual(len(merged["content"]), 2)
self.assertEqual(merged["content"][0]["type"], "text")
self.assertEqual(merged["content"][0]["text"], "First message")
self.assertEqual(merged["content"][1]["type"], "text")
self.assertEqual(merged["content"][1]["text"], "Second message")
self.assertEqual(params["messages"][1]["role"], "assistant")
self.assertEqual(params["messages"][2]["role"], "user")
def test_non_initial_system_converted_to_user(self):
"""Test that non-initial system messages are converted to user and merged with adjacent user."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
{"role": "system", "content": "Be concise."},
{"role": "user", "content": "Tell me about Python."},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
# system(initial), user, assistant, merged(system→user + user)
self.assertEqual(len(params["messages"]), 4)
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][1]["role"], "user")
self.assertEqual(params["messages"][2]["role"], "assistant")
# The converted system→user and the following user should be merged
merged = params["messages"][3]
self.assertEqual(merged["role"], "user")
self.assertIsInstance(merged["content"], list)
self.assertEqual(len(merged["content"]), 2)
self.assertEqual(merged["content"][0]["text"], "Be concise.")
self.assertEqual(merged["content"][1]["text"], "Tell me about Python.")
def test_multiple_system_messages_at_start_preserved(self):
"""Test that multiple consecutive system messages at start pass through unchanged."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "system", "content": "Always be polite."},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
self.assertEqual(params["messages"][1]["role"], "system")
self.assertEqual(params["messages"][1]["content"], "Always be polite.")
self.assertEqual(params["messages"][2]["role"], "user")
self.assertEqual(params["messages"][2]["content"], "Hello")
def test_trailing_assistant_removed(self):
"""Test that a trailing assistant message is removed."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "Hello")
def test_only_system_messages_preserved(self):
"""Test that system-only contexts are left unchanged (no system→user conversion).
We intentionally do not convert trailing system messages to "user"
because that would make the transformation unstable across calls —
Perplexity has statefulness within a conversation, so a message that
was "user" in one call but becomes "system" in the next causes errors.
"""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "system")
def test_system_exposed_after_trailing_assistant_removed(self):
"""Test that a system message exposed by trailing assistant removal stays system.
It's important that initial system messages are never converted to
"user", because Perplexity has statefulness within a conversation — if
a message was sent as "system" in one call and then becomes "user" in a
later call (after more messages are appended), the API rejects it.
"""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "Sure thing."},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
# Trailing assistant removed → [system], system stays as-is
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "system")
self.assertEqual(params["messages"][0]["content"], "You are helpful.")
def test_consecutive_assistants_merged_then_trailing_removed(self):
"""Test that consecutive assistant messages are merged, then trailing assistant is removed."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "First response"},
{"role": "assistant", "content": "Second response"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
# After merging assistants we get [user, assistant(merged)], then trailing
# assistant is removed, leaving just [user]
self.assertEqual(len(params["messages"]), 1)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "Hello")
def test_tool_messages_preserved(self):
"""Test that tool messages pass through without modification."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "Let me check.",
"tool_calls": [{"id": "1", "function": {"name": "get_weather", "arguments": "{}"}}],
},
{"role": "tool", "content": "Sunny, 72F", "tool_call_id": "1"},
{"role": "user", "content": "Thanks!"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 4)
self.assertEqual(params["messages"][0]["role"], "user")
self.assertEqual(params["messages"][1]["role"], "assistant")
self.assertEqual(params["messages"][2]["role"], "tool")
self.assertEqual(params["messages"][2]["content"], "Sunny, 72F")
self.assertEqual(params["messages"][3]["role"], "user")
def test_empty_messages(self):
"""Test that empty messages list returns empty."""
context = LLMContext(messages=[])
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(params["messages"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,81 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for Google LLM OpenAI Beta service."""
import asyncio
import warnings
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
try:
from pipecat.services.google.openai.llm import GoogleLLMOpenAIBetaService
google_available = True
except Exception:
google_available = False
@pytest.mark.asyncio
@pytest.mark.skipif(not google_available, reason="Google dependencies not installed")
async def test_google_llm_openai_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3639.
"""
with patch.object(GoogleLLMOpenAIBetaService, "create_client"):
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
service = GoogleLLMOpenAIBetaService(api_key="test-key", model="test-model")
service._client = AsyncMock()
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
nonlocal stream_closed
stream_closed = True
return False
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
raise asyncio.CancelledError()
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = OpenAILLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
assert stream_closed, "Stream should be closed even when CancelledError occurs"

View File

@@ -22,3 +22,7 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
self.assertEqual(await strategy.should_interrupt(), False)
await strategy.append_text(" How are you?")
self.assertEqual(await strategy.should_interrupt(), True)
if __name__ == "__main__":
unittest.main()

View File

@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.extensions.ivr.ivr_navigator import IVRProcessor
from pipecat.frames.frames import (
AggregatedTextFrame,
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame,
LLMTextFrame,
@@ -339,7 +340,7 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
]
expected_down_frames = [
LLMTextFrame, # Should pass through unchanged
AggregatedTextFrame, # LLMTextFrames aggregrated and converted to AggregatedTextFrame
LLMFullResponseEndFrame,
]
@@ -353,3 +354,7 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
expected_up_frames=expected_up_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -194,3 +194,7 @@ class TestSampleRateConversion:
expected_rates = [8000, 16000, 24000, 32000, 44100, 48000]
for rate in expected_rates:
assert rate in KRISP_SAMPLE_RATES
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2025 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -9,7 +9,7 @@ import os
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import numpy as np

View File

@@ -24,10 +24,15 @@ from pipecat.frames.frames import (
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.processors.frameworks.langchain import LangchainProcessor
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_strategies import UserTurnStrategies
class TestLangchain(unittest.IsolatedAsyncioTestCase):
@@ -65,7 +70,12 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
self.mock_proc = self.MockProcessor("token_collector")
context = LLMContext()
context_aggregator = LLMContextAggregatorPair(context)
context_aggregator = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(
user_turn_strategies=UserTurnStrategies(stop=[SpeechTimeoutUserTurnStopStrategy()])
),
)
pipeline = Pipeline(
[context_aggregator.user(), proc, self.mock_proc, context_aggregator.assistant()]
@@ -97,3 +107,7 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
context_aggregator.assistant().messages[-1]["content"], self.expected_response
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,124 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for LiveKit transport video stream handling.
Regression tests for issue #3116: Memory leak when video_in_enabled=False
but video tracks are subscribed. The fix ensures video stream processing
only starts when there is a consumer for the frames.
"""
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
try:
from livekit import rtc
from pipecat.transports.livekit.transport import (
LiveKitCallbacks,
LiveKitParams,
LiveKitTransportClient,
)
LIVEKIT_AVAILABLE = True
except ImportError:
LIVEKIT_AVAILABLE = False
@unittest.skipUnless(LIVEKIT_AVAILABLE, "livekit package not installed")
class TestLiveKitVideoStreamMemoryLeak(unittest.IsolatedAsyncioTestCase):
"""Regression tests for video queue memory leak (#3116).
The bug: When video_in_enabled=False, subscribing to a video track would
start a producer that fills _video_queue, but no consumer would drain it,
causing unbounded memory growth (~3GB/min).
The fix: Only start video stream processing when video_in_enabled=True.
"""
def _create_client(self, video_in_enabled: bool) -> LiveKitTransportClient:
"""Create a client with the specified video input setting."""
params = LiveKitParams(video_in_enabled=video_in_enabled)
callbacks = LiveKitCallbacks(
on_connected=AsyncMock(),
on_disconnected=AsyncMock(),
on_before_disconnect=AsyncMock(),
on_participant_connected=AsyncMock(),
on_participant_disconnected=AsyncMock(),
on_audio_track_subscribed=AsyncMock(),
on_audio_track_unsubscribed=AsyncMock(),
on_video_track_subscribed=AsyncMock(),
on_video_track_unsubscribed=AsyncMock(),
on_data_received=AsyncMock(),
on_first_participant_joined=AsyncMock(),
)
client = LiveKitTransportClient(
url="wss://test.livekit.cloud",
token="test-token",
room_name="test-room",
params=params,
callbacks=callbacks,
transport_name="test-transport",
)
client._task_manager = MagicMock()
return client
def _create_mock_video_track(self):
"""Create a mock video track subscription event."""
track = MagicMock()
track.kind = rtc.TrackKind.KIND_VIDEO
track.sid = "video-track-123"
publication = MagicMock()
participant = MagicMock()
participant.sid = "participant-456"
return track, publication, participant
async def test_disabled_video_input_does_not_start_queue_producer(self):
"""When video input is disabled, no producer should fill the queue.
This prevents the memory leak where frames accumulate with no consumer.
"""
client = self._create_client(video_in_enabled=False)
track, publication, participant = self._create_mock_video_track()
await client._async_on_track_subscribed(track, publication, participant)
# Verify no video processing task was started
task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list]
video_tasks = [name for name in task_names if "video" in name.lower()]
self.assertEqual(video_tasks, [], "No video processing task should be started")
# Queue should remain empty
self.assertEqual(client._video_queue.qsize(), 0)
# Track metadata should still be recorded
self.assertIn(participant.sid, client._video_tracks)
# Callback should still fire for user code
client._callbacks.on_video_track_subscribed.assert_called_once()
async def test_enabled_video_input_starts_queue_producer(self):
"""When video input is enabled, the producer should start."""
client = self._create_client(video_in_enabled=True)
track, publication, participant = self._create_mock_video_track()
with patch.object(rtc, "VideoStream"):
await client._async_on_track_subscribed(track, publication, participant)
# Verify video processing task was started
task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list]
video_tasks = [name for name in task_names if "video" in name.lower()]
self.assertEqual(len(video_tasks), 1, "Video processing task should be started")
# Track metadata should be recorded
self.assertIn(participant.sid, client._video_tracks)
# Callback should fire
client._callbacks.on_video_track_subscribed.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,765 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
InterruptionFrame,
LLMContextSummaryRequestFrame,
LLMContextSummaryResultFrame,
LLMFullResponseStartFrame,
LLMSummarizeContextFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_context_summarizer import (
LLMContextSummarizer,
SummaryAppliedEvent,
)
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
from pipecat.utils.context.llm_context_summarization import (
LLMAutoContextSummarizationConfig,
LLMContextSummaryConfig,
)
class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
self.context = LLMContext(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
]
)
async def test_summarization_triggered_by_token_limit(self):
"""Test that summarization is triggered when token limit is reached."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100, # Very low to trigger easily
max_unsummarized_messages=100, # High so it doesn't trigger by message count
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages to exceed token limit
for i in range(10):
self.context.add_message(
{
"role": "user",
"content": "This is a test message that adds tokens to the context.",
}
)
# Trigger check by processing LLMFullResponseStartFrame
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should have triggered summarization
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
self.assertEqual(request_frame.context, self.context)
await summarizer.cleanup()
async def test_summarization_triggered_by_message_count(self):
"""Test that summarization is triggered when message count threshold is reached."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # Very high so it doesn't trigger by tokens
max_unsummarized_messages=5, # Low to trigger easily
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages to exceed message count
for i in range(6):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Trigger check
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should have triggered summarization
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
await summarizer.cleanup()
async def test_summarization_not_triggered_below_thresholds(self):
"""Test that summarization is not triggered when below thresholds."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=10000,
max_unsummarized_messages=20,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add a few messages (below threshold)
for i in range(3):
self.context.add_message({"role": "user", "content": "Short message"})
# Trigger check
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should NOT have triggered summarization
self.assertIsNone(request_frame)
await summarizer.cleanup()
async def test_summarization_in_progress_prevents_duplicate(self):
"""Test that a summarization in progress prevents triggering another."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50, # Very low
max_unsummarized_messages=100,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
# Add enough messages to trigger
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message to add tokens."})
# First trigger - should request summarization
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
# Second trigger while first is in progress - should NOT request again
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
await summarizer.cleanup()
async def test_summary_result_handling(self):
"""Test that summary results are processed and applied correctly."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
original_message_count = len(self.context.messages)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
# Simulate receiving a summary result
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="This is a test summary.",
last_summarized_index=5,
error=None,
)
await summarizer.process_frame(summary_result)
# Should have applied the summary and reduced message count
# Expected: system message + summary message + 2 recent messages = 4 messages
# (since last_summarized_index=5, we keep messages after index 5)
self.assertLess(len(self.context.messages), original_message_count)
# Check that summary was added
summary_messages = [
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
]
self.assertEqual(len(summary_messages), 1)
await summarizer.cleanup()
async def test_interruption_cancels_summarization(self):
"""Test that an interruption cancels pending summarization."""
config = LLMAutoContextSummarizationConfig(max_context_tokens=50)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 1)
# Process interruption
await summarizer.process_frame(InterruptionFrame())
# Try to trigger again - should work since the previous one was canceled
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertEqual(request_count, 2)
await summarizer.cleanup()
async def test_stale_summary_result_ignored(self):
"""Test that stale summary results are ignored."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
original_message_count = len(self.context.messages)
await summarizer.process_frame(LLMFullResponseStartFrame())
valid_request_id = request_frame.request_id
# Send a stale summary result (wrong request_id)
stale_result = LLMContextSummaryResultFrame(
request_id="stale-id-123",
summary="Stale summary",
last_summarized_index=3,
error=None,
)
await summarizer.process_frame(stale_result)
# Should be ignored - message count should not change
self.assertEqual(len(self.context.messages), original_message_count)
# Send the correct summary result
valid_result = LLMContextSummaryResultFrame(
request_id=valid_request_id,
summary="Valid summary",
last_summarized_index=5,
error=None,
)
await summarizer.process_frame(valid_result)
# Should be processed - message count should decrease
self.assertLess(len(self.context.messages), original_message_count)
# Check that summary was added
summary_messages = [
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
]
self.assertEqual(len(summary_messages), 1)
await summarizer.cleanup()
async def test_manual_summarization_via_frame(self):
"""Test that LLMSummarizeContextFrame triggers summarization on demand."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # High — auto trigger would never fire
max_unsummarized_messages=100,
)
summarizer = LLMContextSummarizer(
context=self.context,
config=config,
auto_trigger=False, # Disable auto; only manual requests should work
)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add messages
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Auto-trigger should NOT fire even on LLMFullResponseStartFrame
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNone(request_frame)
# Manual trigger via LLMSummarizeContextFrame should fire
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertIsNotNone(request_frame)
self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame)
# The request must have a valid request_id and carry the current context
self.assertTrue(request_frame.request_id)
self.assertEqual(request_frame.context, self.context)
await summarizer.cleanup()
async def test_manual_summarization_with_config_override(self):
"""Test that LLMSummarizeContextFrame can override default summary config."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000,
summary_config=LLMContextSummaryConfig(
target_context_tokens=6000,
min_messages_after_summary=4,
),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# Push a manual frame with custom config overrides
custom_config = LLMContextSummaryConfig(
target_context_tokens=500,
min_messages_after_summary=1,
)
await summarizer.process_frame(LLMSummarizeContextFrame(config=custom_config))
self.assertIsNotNone(request_frame)
# The request should use the overridden values
self.assertEqual(request_frame.target_context_tokens, 500)
self.assertEqual(request_frame.min_messages_to_keep, 1)
await summarizer.cleanup()
async def test_manual_summarization_blocked_when_in_progress(self):
"""Test that a second LLMSummarizeContextFrame is ignored while one is in progress."""
config = LLMAutoContextSummarizationConfig(max_context_tokens=100000)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_count = 0
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_count
request_count += 1
for i in range(5):
self.context.add_message({"role": "user", "content": f"Message {i}"})
# First manual request
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertEqual(request_count, 1)
# Second manual request while first is in progress — should be ignored
await summarizer.process_frame(LLMSummarizeContextFrame())
self.assertEqual(request_count, 1)
await summarizer.cleanup()
async def test_summary_message_role_is_user(self):
"""Test that the summary message uses the user role."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages and trigger summarization
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
# Simulate receiving a summary result
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="This is a test summary.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Find the summary message and verify its role is "user"
summary_msg = next(
(msg for msg in self.context.messages if "summary" in msg.get("content", "").lower()),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(summary_msg["role"], "user")
await summarizer.cleanup()
async def test_summary_message_default_template(self):
"""Test that the default summary_message_template is used."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Key facts from conversation.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Default template wraps with "Conversation summary: {summary}"
summary_msg = next(
(
msg
for msg in self.context.messages
if "Conversation summary:" in msg.get("content", "")
),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(
summary_msg["content"], "Conversation summary: Key facts from conversation."
)
await summarizer.cleanup()
async def test_summary_message_custom_template(self):
"""Test that a custom summary_message_template is applied."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(
min_messages_after_summary=2,
summary_message_template="<context_summary>\n{summary}\n</context_summary>",
),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
await summarizer.process_frame(LLMFullResponseStartFrame())
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Key facts from conversation.",
last_summarized_index=5,
)
await summarizer.process_frame(summary_result)
# Custom template wraps with XML tags
summary_msg = next(
(msg for msg in self.context.messages if "<context_summary>" in msg.get("content", "")),
None,
)
self.assertIsNotNone(summary_msg)
self.assertEqual(
summary_msg["content"],
"<context_summary>\nKey facts from conversation.\n</context_summary>",
)
await summarizer.cleanup()
async def test_on_summary_applied_event(self):
"""Test that on_summary_applied event fires with correct data."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
# Add messages (1 system + 10 user = 11 total)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
applied_event = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
@summarizer.event_handler("on_summary_applied")
async def on_summary_applied(summarizer, event):
nonlocal applied_event
applied_event = event
original_count = len(self.context.messages) # 11
await summarizer.process_frame(LLMFullResponseStartFrame())
# Summarize up to index 7 (system=0, user1..user7), keep last 3 (user8, user9, user10)
summary_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="Test summary.",
last_summarized_index=7,
)
await summarizer.process_frame(summary_result)
# Allow async event handler to complete
await asyncio.sleep(0.05)
# Verify event was fired
self.assertIsNotNone(applied_event)
self.assertIsInstance(applied_event, SummaryAppliedEvent)
self.assertEqual(applied_event.original_message_count, original_count)
# After summarization: system + summary + 3 recent = 5
self.assertEqual(applied_event.new_message_count, 5)
# Summarized messages: indices 1-7 = 7 messages (excluding system at index 0)
self.assertEqual(applied_event.summarized_message_count, 7)
# Preserved: system (1) + recent messages after index 7 (3) = 4
self.assertEqual(applied_event.preserved_message_count, 4)
await summarizer.cleanup()
async def test_on_summary_applied_not_fired_on_error(self):
"""Test that on_summary_applied event is NOT fired when summarization fails."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(min_messages_after_summary=2),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message."})
request_frame = None
applied_event = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
@summarizer.event_handler("on_summary_applied")
async def on_summary_applied(summarizer, event):
nonlocal applied_event
applied_event = event
await summarizer.process_frame(LLMFullResponseStartFrame())
# Send a result with an error
error_result = LLMContextSummaryResultFrame(
request_id=request_frame.request_id,
summary="",
last_summarized_index=-1,
error="Summarization timed out",
)
await summarizer.process_frame(error_result)
await asyncio.sleep(0.05)
# Event should NOT have fired
self.assertIsNone(applied_event)
await summarizer.cleanup()
async def test_request_frame_includes_timeout(self):
"""Test that the request frame includes the configured summarization_timeout."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=50,
summary_config=LLMContextSummaryConfig(summarization_timeout=60.0),
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
for i in range(10):
self.context.add_message({"role": "user", "content": "Test message to add tokens."})
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
self.assertEqual(request_frame.summarization_timeout, 60.0)
await summarizer.cleanup()
async def test_token_limit_none_only_message_threshold(self):
"""Test that only message threshold triggers when token limit is None."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=None,
max_unsummarized_messages=5,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many tokens but fewer than 5 messages — should NOT trigger
for i in range(3):
self.context.add_message(
{"role": "user", "content": "x" * 10000} # Lots of tokens
)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNone(request_frame)
# Cross the message threshold (5 messages since summary = 6 total including system)
for i in range(3):
self.context.add_message({"role": "user", "content": f"Message {i}"})
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
await summarizer.cleanup()
async def test_message_limit_none_only_token_threshold(self):
"""Test that only token threshold triggers when message limit is None."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100, # Very low
max_unsummarized_messages=None,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many messages that exceed the token limit
for i in range(10):
self.context.add_message(
{"role": "user", "content": "This is a test message with enough tokens."}
)
await summarizer.process_frame(LLMFullResponseStartFrame())
self.assertIsNotNone(request_frame)
await summarizer.cleanup()
async def test_message_limit_none_no_trigger_below_tokens(self):
"""Test that many messages don't trigger when message limit is None and tokens are low."""
config = LLMAutoContextSummarizationConfig(
max_context_tokens=100000, # Very high
max_unsummarized_messages=None,
)
summarizer = LLMContextSummarizer(context=self.context, config=config)
await summarizer.setup(self.task_manager)
request_frame = None
@summarizer.event_handler("on_request_summarization")
async def on_request_summarization(summarizer, frame):
nonlocal request_frame
request_frame = frame
# Add many short messages — would exceed any reasonable message count
# but tokens stay well below the limit
for i in range(50):
self.context.add_message({"role": "user", "content": f"Msg {i}"})
await summarizer.process_frame(LLMFullResponseStartFrame())
# Should NOT trigger because token limit is not exceeded
self.assertIsNone(request_frame)
await summarizer.cleanup()
if __name__ == "__main__":
unittest.main()

View File

@@ -134,3 +134,7 @@ class TestLLMFullResponseAggregator(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
assert completion_ok
if __name__ == "__main__":
unittest.main()

View File

@@ -244,3 +244,7 @@ class TestMarkdownTextFilter(unittest.IsolatedAsyncioTestCase):
"bold and italic",
"Text filtering should be re-enabled",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,299 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for OpenAI LLM error handling."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from pipecat.frames.frames import (
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.llm import OpenAILLMService
@pytest.mark.asyncio
async def test_openai_llm_emits_error_frame_on_timeout():
"""Test that OpenAI LLM service emits ErrorFrame when a timeout occurs.
This enables LLMSwitcher to trigger failover to backup LLMs when the
primary LLM times out.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track pushed frames and errors
pushed_frames = []
pushed_errors = []
timeout_handler_called = False
original_push_frame = service.push_frame
async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pushed_frames.append(frame)
await original_push_frame(frame, direction)
async def mock_push_error(error_msg, exception=None):
pushed_errors.append({"error_msg": error_msg, "exception": exception})
async def mock_timeout_handler(event_name):
nonlocal timeout_handler_called
if event_name == "on_completion_timeout":
timeout_handler_called = True
service.push_frame = mock_push_frame
service.push_error = mock_push_error
service._call_event_handler = AsyncMock(side_effect=mock_timeout_handler)
# Mock _process_context to raise TimeoutException
service._process_context = AsyncMock(
side_effect=httpx.TimeoutException("Connection timed out")
)
# Mock metrics methods
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
# Create a context frame to process
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
# Process the frame
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify timeout handler was called
service._call_event_handler.assert_called_once_with("on_completion_timeout")
assert timeout_handler_called
# Verify push_error was called with correct message
assert len(pushed_errors) == 1
assert pushed_errors[0]["error_msg"] == "LLM completion timeout"
assert isinstance(pushed_errors[0]["exception"], httpx.TimeoutException)
# Verify LLMFullResponseStartFrame and LLMFullResponseEndFrame were pushed
frame_types = [type(f) for f in pushed_frames]
assert LLMFullResponseStartFrame in frame_types
assert LLMFullResponseEndFrame in frame_types
@pytest.mark.asyncio
async def test_openai_llm_timeout_still_pushes_end_frame():
"""Test that LLMFullResponseEndFrame is pushed even when timeout occurs.
The finally block should ensure proper cleanup regardless of timeout.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
pushed_frames = []
async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM):
pushed_frames.append(frame)
service.push_frame = mock_push_frame
service.push_error = AsyncMock()
service._call_event_handler = AsyncMock()
service._process_context = AsyncMock(side_effect=httpx.TimeoutException("Timeout"))
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify both start and end frames are pushed
frame_types = [type(f) for f in pushed_frames]
assert LLMFullResponseStartFrame in frame_types
assert LLMFullResponseEndFrame in frame_types
# Verify metrics were stopped
service.stop_processing_metrics.assert_called_once()
@pytest.mark.asyncio
async def test_openai_llm_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3589.
"""
import asyncio
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track if close was called
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def close(self):
nonlocal stream_closed
stream_closed = True
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
# Simulate cancellation during iteration
raise asyncio.CancelledError()
# Return a minimal chunk for first iteration
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.model = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
# Mock the stream creation methods
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
# Process context should raise CancelledError but stream should still be closed
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
# Verify stream was closed despite the cancellation
assert stream_closed, "Stream should be closed even when CancelledError occurs"
@pytest.mark.asyncio
async def test_openai_llm_emits_error_frame_on_exception():
"""Test that OpenAI LLM service emits ErrorFrame when a general exception occurs.
This enables proper error handling for API errors, rate limits, and other failures.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
pushed_errors = []
async def mock_push_error(error_msg, exception=None):
pushed_errors.append({"error_msg": error_msg, "exception": exception})
service.push_frame = AsyncMock()
service.push_error = mock_push_error
service._call_event_handler = AsyncMock()
service._process_context = AsyncMock(side_effect=RuntimeError("API Error"))
service.start_processing_metrics = AsyncMock()
service.stop_processing_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
frame = LLMContextFrame(context=context)
await service.process_frame(frame, FrameDirection.DOWNSTREAM)
# Verify push_error was called with correct message
assert len(pushed_errors) == 1
assert "Error during completion" in pushed_errors[0]["error_msg"]
assert "API Error" in pushed_errors[0]["error_msg"]
assert isinstance(pushed_errors[0]["exception"], RuntimeError)
@pytest.mark.asyncio
async def test_openai_llm_async_iterator_closed_on_stream_end():
"""Test that the async iterator is explicitly closed after stream consumption.
This prevents uvloop's broken asyncgen finalizer from firing on Python 3.12+
when async generators are garbage-collected without explicit cleanup.
See MagicStack/uvloop#699.
"""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
# Track if the iterator's aclose was called
iterator_aclosed = False
stream_closed = False
class MockAsyncIterator:
"""Mock async iterator that tracks aclose() calls."""
def __init__(self):
self.iteration_count = 0
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 2:
raise StopAsyncIteration()
# Return a minimal chunk
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.model = None
mock_chunk.choices = []
return mock_chunk
async def aclose(self):
nonlocal iterator_aclosed
iterator_aclosed = True
class MockAsyncStream:
"""Mock stream whose __aiter__ returns a separate iterator object."""
def __init__(self, iterator):
self._iterator = iterator
def __aiter__(self):
return self._iterator
async def close(self):
nonlocal stream_closed
stream_closed = True
mock_iterator = MockAsyncIterator()
mock_stream = MockAsyncStream(mock_iterator)
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
await service._process_context(context)
# Verify the iterator was explicitly closed (prevents uvloop crash)
assert iterator_aclosed, "Async iterator should be explicitly closed"
# Verify the stream was also closed (releases HTTP resources)
assert stream_closed, "Stream should be closed to release HTTP resources"

View File

@@ -0,0 +1,349 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for the OpenAI Responses API adapter.
Tests the conversion from LLMContext messages to Responses API input items, including:
1. Simple user/assistant text messages pass through (with correct role)
2. System role converted to developer role
3. First-message system role triggers a warning
4. Assistant messages with tool_calls produce function_call input items
5. Tool messages produce function_call_output input items
6. Mixed conversations with text + function calls convert correctly
7. Multimodal content conversion (text -> input_text, image_url -> input_image)
8. Tools schema flattening (nested function dict -> flat format)
9. Empty messages list
10. LLMSpecificMessage with llm="openai_responses" passes through
"""
import unittest
from unittest.mock import patch
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.adapters.services.open_ai_responses_adapter import OpenAIResponsesLLMAdapter
from pipecat.processors.aggregators.llm_context import LLMContext, LLMStandardMessage
class TestOpenAIResponsesAdapter(unittest.TestCase):
def setUp(self):
self.adapter = OpenAIResponsesLLMAdapter()
def test_simple_user_assistant_messages(self):
"""Simple user/assistant text messages are converted correctly."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 2)
self.assertEqual(params["input"][0], {"role": "user", "content": "Hello"})
self.assertEqual(params["input"][1], {"role": "assistant", "content": "Hi there!"})
def test_system_role_converted_to_developer(self):
"""System role messages are converted to developer role."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(params["input"][0]["role"], "developer")
self.assertEqual(params["input"][0]["content"], "You are helpful.")
def test_first_system_message_triggers_warning(self):
"""First system message triggers a warning about using system_instruction."""
# Use a fresh adapter so the warning hasn't been emitted yet
adapter = OpenAIResponsesLLMAdapter()
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
adapter.get_llm_invocation_params(context)
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
self.assertIn("system_instruction", warning_msg)
def test_non_initial_system_message_no_warning(self):
"""Non-initial system messages are converted without a warning."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
{"role": "system", "content": "New instruction"},
]
context = LLMContext(messages=messages)
adapter = OpenAIResponsesLLMAdapter()
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
params = adapter.get_llm_invocation_params(context)
mock_logger.warning.assert_not_called()
self.assertEqual(params["input"][1]["role"], "developer")
self.assertEqual(params["input"][1]["content"], "New instruction")
def test_first_system_message_warning_fires_only_once(self):
"""The first-system-message warning fires only once per adapter instance."""
messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
adapter = OpenAIResponsesLLMAdapter()
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
adapter.get_llm_invocation_params(context)
adapter.get_llm_invocation_params(context)
# Warning should have been emitted exactly once, not twice
mock_logger.warning.assert_called_once()
def test_assistant_tool_calls_to_function_call(self):
"""Assistant messages with tool_calls produce function_call input items."""
messages = [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_123",
"function": {
"name": "get_weather",
"arguments": '{"location": "SF"}',
},
"type": "function",
}
],
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 1)
fc = params["input"][0]
self.assertEqual(fc["type"], "function_call")
self.assertEqual(fc["call_id"], "call_123")
self.assertEqual(fc["name"], "get_weather")
self.assertEqual(fc["arguments"], '{"location": "SF"}')
def test_multiple_tool_calls(self):
"""Multiple tool calls in one assistant message produce multiple function_call items."""
messages = [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"function": {"name": "get_weather", "arguments": '{"location": "SF"}'},
"type": "function",
},
{
"id": "call_2",
"function": {"name": "get_restaurant", "arguments": '{"location": "SF"}'},
"type": "function",
},
],
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 2)
self.assertEqual(params["input"][0]["name"], "get_weather")
self.assertEqual(params["input"][1]["name"], "get_restaurant")
def test_tool_message_to_function_call_output(self):
"""Tool role messages produce function_call_output input items."""
messages = [
{
"role": "tool",
"content": '{"temperature": "72"}',
"tool_call_id": "call_123",
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 1)
fco = params["input"][0]
self.assertEqual(fco["type"], "function_call_output")
self.assertEqual(fco["call_id"], "call_123")
self.assertEqual(fco["output"], '{"temperature": "72"}')
def test_mixed_conversation(self):
"""Mixed conversation with text + function calls converts correctly."""
messages = [
{"role": "user", "content": "What's the weather in SF?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_abc",
"function": {"name": "get_weather", "arguments": '{"location": "SF"}'},
"type": "function",
}
],
},
{
"role": "tool",
"content": '{"temp": "72"}',
"tool_call_id": "call_abc",
},
{"role": "assistant", "content": "It's 72 degrees in SF."},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 4)
self.assertEqual(params["input"][0]["role"], "user")
self.assertEqual(params["input"][1]["type"], "function_call")
self.assertEqual(params["input"][2]["type"], "function_call_output")
self.assertEqual(params["input"][3]["role"], "assistant")
def test_multimodal_text_conversion(self):
"""Multimodal text content parts are converted to input_text."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
],
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
content = params["input"][0]["content"]
self.assertEqual(len(content), 1)
self.assertEqual(content[0]["type"], "input_text")
self.assertEqual(content[0]["text"], "What's in this image?")
def test_multimodal_image_conversion(self):
"""Multimodal image_url content parts are converted to input_image."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this:"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abc123"},
},
],
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
content = params["input"][0]["content"]
self.assertEqual(len(content), 2)
self.assertEqual(content[0]["type"], "input_text")
self.assertEqual(content[1]["type"], "input_image")
self.assertEqual(content[1]["image_url"], "data:image/jpeg;base64,abc123")
self.assertEqual(content[1]["detail"], "auto")
def test_multimodal_image_with_detail(self):
"""Image content parts preserve the detail setting when provided."""
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "https://example.com/img.png", "detail": "high"},
},
],
}
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
content = params["input"][0]["content"]
self.assertEqual(content[0]["detail"], "high")
def test_tools_schema_flattening(self):
"""Tools schema with nested function dict is flattened to Responses API format."""
weather_fn = FunctionSchema(
name="get_weather",
description="Get the current weather",
properties={
"location": {"type": "string", "description": "The city"},
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_fn])
context = LLMContext(tools=tools)
params = self.adapter.get_llm_invocation_params(context)
tool_list = params["tools"]
self.assertEqual(len(tool_list), 1)
tool = tool_list[0]
self.assertEqual(tool["type"], "function")
self.assertEqual(tool["name"], "get_weather")
self.assertEqual(tool["description"], "Get the current weather")
self.assertIn("properties", tool["parameters"])
def test_empty_messages(self):
"""Empty messages list produces empty input list."""
context = LLMContext(messages=[])
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(params["input"], [])
def test_llm_specific_message_passthrough(self):
"""LLMSpecificMessage with llm='openai_responses' passes through."""
specific_msg = self.adapter.create_llm_specific_message(
{"type": "function_call", "call_id": "x", "name": "foo", "arguments": "{}"}
)
messages = [
{"role": "user", "content": "Hello"},
specific_msg,
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["input"]), 2)
self.assertEqual(params["input"][0]["role"], "user")
self.assertEqual(params["input"][1]["type"], "function_call")
def test_id_for_llm_specific_messages(self):
"""Adapter identifier is 'openai_responses'."""
self.assertEqual(self.adapter.id_for_llm_specific_messages, "openai_responses")
def test_system_instruction_with_messages_sets_instructions(self):
"""When system_instruction is provided and input is non-empty, sets instructions."""
messages: list[LLMStandardMessage] = [
{"role": "user", "content": "Hello"},
]
context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
self.assertEqual(params["instructions"], "Be helpful.")
self.assertEqual(len(params["input"]), 1)
self.assertEqual(params["input"][0]["role"], "user")
def test_system_instruction_with_empty_input_becomes_developer_message(self):
"""When system_instruction is provided but input is empty, it becomes a developer message."""
context = LLMContext(messages=[])
params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
self.assertNotIn("instructions", params)
self.assertEqual(len(params["input"]), 1)
self.assertEqual(params["input"][0]["role"], "developer")
self.assertEqual(params["input"][0]["content"], "Be helpful.")
def test_no_system_instruction_omits_instructions(self):
"""When no system_instruction is provided, instructions key is absent."""
context = LLMContext(messages=[{"role": "user", "content": "Hi"}])
params = self.adapter.get_llm_invocation_params(context)
self.assertNotIn("instructions", params)
if __name__ == "__main__":
unittest.main()

View File

@@ -192,3 +192,68 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
# Buffer should be empty
self.assertEqual(self.aggregator.text.text, "")
class TestPatternPairAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = PatternPairAggregator(aggregation_type=AggregationType.TOKEN)
self.handler = AsyncMock()
self.aggregator.add_pattern(
type="think",
start_pattern="<think>",
end_pattern="</think>",
action=MatchAction.REMOVE,
)
self.aggregator.on_pattern_match("think", self.handler)
async def test_token_no_patterns(self):
"""Non-pattern text passes through as TOKEN, one per aggregate call."""
results = []
for token in ["Hello", " world", "."]:
async for r in self.aggregator.aggregate(token):
results.append(r)
self.assertEqual(len(results), 3)
self.assertEqual(results[0].text, "Hello")
self.assertEqual(results[1].text, " world")
self.assertEqual(results[2].text, ".")
for r in results:
self.assertEqual(r.type, "token")
async def test_token_pattern_detection(self):
"""Pattern detection still works with word-by-word token delivery."""
results = []
for token in ["Hi ", "<think>", "secret", "</think>", " bye"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Handler called once when the pattern completes
self.handler.assert_called_once()
call_args = self.handler.call_args[0][0]
self.assertEqual(call_args.text, "secret")
# "Hi " yields before pattern starts, pattern is removed, " bye" yields after
self.assertEqual(len(results), 2)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.assertEqual(results[1].text, " bye")
self.assertEqual(results[1].type, "token")
async def test_token_incomplete_pattern_buffers(self):
"""Incomplete pattern is buffered across calls, not leaked to output."""
results = []
for token in ["Hi ", "<think>", "partial"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Only "Hi " should be yielded; "<think>partial" stays buffered
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.handler.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -96,6 +96,34 @@ class TestParallelPipeline(unittest.IsolatedAsyncioTestCase):
expected_down_frames=expected_down_frames,
)
async def test_parallel_internal_frames_buffered_during_start(self):
"""Frames pushed by internal processors during StartFrame processing
should be buffered and only released after StartFrame synchronization
completes."""
class EmitOnStartProcessor(FrameProcessor):
"""Pushes a TextFrame when it receives a StartFrame."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
if isinstance(frame, StartFrame):
await self.push_frame(TextFrame(text="from start"))
pipeline = ParallelPipeline([EmitOnStartProcessor()], [IdentityFilter()])
frames_to_send = [TextFrame(text="Hello!")]
# StartFrame should come first, then the TextFrame emitted during
# StartFrame processing, then the regular TextFrame.
expected_down_frames = [StartFrame, TextFrame, TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
ignore_start=False,
)
class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
async def test_task_single(self):
@@ -264,6 +292,63 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
assert upstream_received
assert downstream_received
async def test_task_queue_frame_upstream(self):
upstream_received = False
pipeline = Pipeline([IdentityFilter()])
task = PipelineTask(pipeline, cancel_on_idle_timeout=False)
task.set_reached_upstream_filter((TextFrame,))
@task.event_handler("on_frame_reached_upstream")
async def on_frame_reached_upstream(task, frame):
nonlocal upstream_received
if isinstance(frame, TextFrame) and frame.text == "Hello Upstream!":
upstream_received = True
@task.event_handler("on_pipeline_started")
async def on_pipeline_started(task, frame):
await task.queue_frame(TextFrame(text="Hello Upstream!"), FrameDirection.UPSTREAM)
try:
await asyncio.wait_for(
task.run(PipelineTaskParams(loop=asyncio.get_event_loop())),
timeout=1.0,
)
except asyncio.TimeoutError:
pass
assert upstream_received
async def test_task_queue_frames_upstream(self):
upstream_texts = []
pipeline = Pipeline([IdentityFilter()])
task = PipelineTask(pipeline, cancel_on_idle_timeout=False)
task.set_reached_upstream_filter((TextFrame,))
@task.event_handler("on_frame_reached_upstream")
async def on_frame_reached_upstream(task, frame):
if isinstance(frame, TextFrame):
upstream_texts.append(frame.text)
@task.event_handler("on_pipeline_started")
async def on_pipeline_started(task, frame):
await task.queue_frames(
[TextFrame(text="First"), TextFrame(text="Second")],
FrameDirection.UPSTREAM,
)
try:
await asyncio.wait_for(
task.run(PipelineTaskParams(loop=asyncio.get_event_loop())),
timeout=1.0,
)
except asyncio.TimeoutError:
pass
assert "First" in upstream_texts
assert "Second" in upstream_texts
async def test_task_heartbeats(self):
heartbeats_counter = 0
@@ -528,3 +613,7 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
await task.run(PipelineTaskParams(loop=asyncio.get_event_loop()))
except asyncio.CancelledError:
assert error_received
if __name__ == "__main__":
unittest.main()

View File

@@ -7,6 +7,7 @@
"""Tests for PiperTTSService."""
import asyncio
import unittest
import aiohttp
import pytest
@@ -21,7 +22,7 @@ from pipecat.frames.frames import (
TTSStoppedFrame,
TTSTextFrame,
)
from pipecat.services.piper.tts import PiperTTSService
from pipecat.services.piper.tts import PiperHttpTTSService
from pipecat.tests.utils import run_test
@@ -67,35 +68,45 @@ async def test_run_piper_tts_success(aiohttp_client):
base_url = str(client.make_url("")).rstrip("/")
async with aiohttp.ClientSession() as session:
# Instantiate PiperTTSService with our mock server
tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000)
# Instantiate PiperHttpTTSService with our mock server
tts_service = PiperHttpTTSService(
base_url=base_url, aiohttp_session=session, sample_rate=24000
)
frames_to_send = [
TTSSpeakFrame(text="Hello world."),
]
expected_returned_frames = [
AggregatedTextFrame,
TTSStartedFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSAudioRawFrame,
TTSStoppedFrame,
TTSTextFrame,
]
frames_received = await run_test(
tts_service,
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
down_frames = frames_received[0]
frame_types = [type(f) for f in down_frames]
# Verify key frames are present
assert AggregatedTextFrame in frame_types
assert TTSStartedFrame in frame_types
assert TTSStoppedFrame in frame_types
assert TTSTextFrame in frame_types
# Verify ordering: Started → audio → Stopped → Text
started_idx = frame_types.index(TTSStartedFrame)
stopped_idx = frame_types.index(TTSStoppedFrame)
text_idx = frame_types.index(TTSTextFrame)
assert started_idx < text_idx < stopped_idx, (
"Expected: TTSStartedFrame < TTSTextFrame < TTSStoppedFrame"
)
# Frames between Started and Stopped must all be audio or text
for i in range(started_idx + 1, stopped_idx):
assert frame_types[i] in (TTSAudioRawFrame, TTSTextFrame), (
f"Unexpected frame type between Started and Stopped: {frame_types[i]}"
)
# All audio frames have correct sample rate
audio_frames = [f for f in down_frames if isinstance(f, TTSAudioRawFrame)]
assert len(audio_frames) >= 1, "Expected at least one audio frame"
for a_frame in audio_frames:
assert a_frame.sample_rate == 24000, "Sample rate should match the default (24000)"
@@ -117,13 +128,15 @@ async def test_run_piper_tts_error(aiohttp_client):
base_url = str(client.make_url("")).rstrip("/")
async with aiohttp.ClientSession() as session:
tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000)
tts_service = PiperHttpTTSService(
base_url=base_url, aiohttp_session=session, sample_rate=24000
)
frames_to_send = [
TTSSpeakFrame(text="Error case."),
TTSSpeakFrame(text="Error case.", append_to_context=False),
]
expected_down_frames = [AggregatedTextFrame, TTSStoppedFrame, TTSTextFrame]
expected_down_frames = [AggregatedTextFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame]
expected_up_frames = [ErrorFrame]
@@ -139,3 +152,7 @@ async def test_run_piper_tts_error(aiohttp_client):
assert "status: 404" in up_frames[0].error, (
"ErrorFrame should contain details about the 404"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -118,3 +118,7 @@ class TestProducerConsumerProcessor(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -38,3 +38,7 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase):
self.assertEqual(frame.audio, audio_frame.audio)
self.assertEqual(frame.sample_rate, audio_frame.sample_rate)
self.assertEqual(frame.num_channels, audio_frame.num_channels)
if __name__ == "__main__":
unittest.main()

View File

@@ -171,3 +171,7 @@ class TestRNNoiseCancellation(unittest.IsolatedAsyncioTestCase):
self.assertLess(mse_output, mse_input, "MSE did not improve")
print("Test Passed: Noise cancellation verified.")
if __name__ == "__main__":
unittest.main()

View File

@@ -5,7 +5,7 @@
#
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock
import numpy as np
@@ -143,3 +143,7 @@ class TestRNNoiseFilter(unittest.IsolatedAsyncioTestCase):
)
await filter.stop()
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import sys
import unittest
from unittest.mock import MagicMock, patch
@@ -133,3 +132,7 @@ class TestRNNoiseResampling(unittest.IsolatedAsyncioTestCase):
)
print("Test Passed: Resampling logic verified (with mocked RNNoise).")
if __name__ == "__main__":
unittest.main()

View File

@@ -20,6 +20,7 @@ from pipecat.services.anthropic.llm import AnthropicLLMService
from pipecat.services.aws.llm import AWSBedrockLLMService
from pipecat.services.google.llm import GoogleLLMService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService
@pytest.mark.asyncio
@@ -509,3 +510,428 @@ async def test_aws_bedrock_run_inference_client_exception():
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
with pytest.raises(Exception, match="Bedrock API Error"):
await service.run_inference(mock_context)
# --- system_instruction parameter tests ---
@pytest.mark.asyncio
async def test_openai_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message from context."""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [
{"role": "system", "content": "Original system message"},
{"role": "user", "content": "Hello"},
]
mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams(
messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Response"
service._client.chat.completions.create.return_value = mock_response
result = await service.run_inference(
mock_context, system_instruction="New system instruction"
)
assert result == "Response"
call_kwargs = service._client.chat.completions.create.call_args.kwargs
messages = call_kwargs["messages"]
# system_instruction should be prepended as the first message
assert messages[0] == {"role": "system", "content": "New system instruction"}
# Original system message should still be present
assert messages[1] == {"role": "system", "content": "Original system message"}
# User message should still be present
assert messages[2] == {"role": "user", "content": "Hello"}
assert len(messages) == 3
@pytest.mark.asyncio
async def test_openai_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, behavior is unchanged."""
with patch.object(OpenAILLMService, "create_client"):
service = OpenAILLMService(model="gpt-4")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [
{"role": "system", "content": "Original system message"},
{"role": "user", "content": "Hello"},
]
mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams(
messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Response"
service._client.chat.completions.create.return_value = mock_response
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.chat.completions.create.call_args.kwargs
messages = call_kwargs["messages"]
assert messages[0] == {"role": "system", "content": "Original system message"}
assert messages[1] == {"role": "user", "content": "Hello"}
@pytest.mark.asyncio
async def test_anthropic_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for Anthropic."""
service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams(
messages=test_messages, system="Original system", tools=[]
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Response"
service._client.beta.messages.create.return_value = mock_response
result = await service.run_inference(mock_context, system_instruction="New system instruction")
assert result == "Response"
call_kwargs = service._client.beta.messages.create.call_args.kwargs
assert call_kwargs["system"] == "New system instruction"
assert call_kwargs["messages"] == test_messages
@pytest.mark.asyncio
async def test_anthropic_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, Anthropic behavior is unchanged."""
service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams(
messages=test_messages, system="Original system", tools=[]
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.content = [MagicMock()]
mock_response.content[0].text = "Response"
service._client.beta.messages.create.return_value = mock_response
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.beta.messages.create.call_args.kwargs
assert call_kwargs["system"] == "Original system"
@pytest.mark.asyncio
async def test_google_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for Google."""
service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams(
messages=test_messages, system_instruction="Original system", tools=NotGiven()
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content = MagicMock()
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = "Response"
service._client.aio = AsyncMock()
service._client.aio.models = AsyncMock()
service._client.aio.models.generate_content = AsyncMock(return_value=mock_response)
result = await service.run_inference(mock_context, system_instruction="New system instruction")
assert result == "Response"
call_kwargs = service._client.aio.models.generate_content.call_args.kwargs
config = call_kwargs["config"]
assert config.system_instruction == "New system instruction"
@pytest.mark.asyncio
async def test_google_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, Google behavior is unchanged."""
service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash")
service._client = AsyncMock()
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": "Hello"}]
mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams(
messages=test_messages, system_instruction="Original system", tools=NotGiven()
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content = MagicMock()
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = "Response"
service._client.aio = AsyncMock()
service._client.aio.models = AsyncMock()
service._client.aio.models.generate_content = AsyncMock(return_value=mock_response)
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = service._client.aio.models.generate_content.call_args.kwargs
config = call_kwargs["config"]
assert config.system_instruction == "Original system"
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_system_instruction_overrides_context():
"""Test that system_instruction overrides the system message for AWS Bedrock."""
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0")
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": [{"text": "Hello"}]}]
mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams(
messages=test_messages,
system=[{"text": "Original system"}],
tools=[],
tool_choice=None,
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_client = AsyncMock()
mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}}
mock_client.converse.return_value = mock_response
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
result = await service.run_inference(
mock_context, system_instruction="New system instruction"
)
assert result == "Response"
call_kwargs = mock_client.converse.call_args.kwargs
assert call_kwargs["system"] == [{"text": "New system instruction"}]
assert call_kwargs["messages"] == test_messages
@pytest.mark.asyncio
async def test_aws_bedrock_run_inference_system_instruction_none_unchanged():
"""Test that when system_instruction is None, AWS Bedrock behavior is unchanged."""
service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0")
mock_context = MagicMock(spec=LLMContext)
mock_adapter = MagicMock()
test_messages = [{"role": "user", "content": [{"text": "Hello"}]}]
mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams(
messages=test_messages,
system=[{"text": "Original system"}],
tools=[],
tool_choice=None,
)
service.get_llm_adapter = MagicMock(return_value=mock_adapter)
mock_client = AsyncMock()
mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}}
mock_client.converse.return_value = mock_response
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
with patch.object(service._aws_session, "client", return_value=mock_context_manager):
result = await service.run_inference(mock_context)
assert result == "Response"
call_kwargs = mock_client.converse.call_args.kwargs
assert call_kwargs["system"] == [{"text": "Original system"}]
# --- OpenAI Responses API tests ---
@pytest.mark.asyncio
async def test_openai_responses_run_inference_with_llm_context():
"""Test run_inference with LLMContext returns expected response."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="You are a helpful assistant",
temperature=0.7,
max_completion_tokens=100,
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[
{"role": "user", "content": "Hello, world!"},
]
)
mock_response = MagicMock()
mock_response.output_text = "Hello! How can I help you today?"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context)
assert result == "Hello! How can I help you today?"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["model"] == "gpt-4.1"
assert call_kwargs["stream"] is False
assert call_kwargs["store"] is False
assert call_kwargs["input"] == [{"role": "user", "content": "Hello, world!"}]
assert call_kwargs["instructions"] == "You are a helpful assistant"
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["max_output_tokens"] == 100
@pytest.mark.asyncio
async def test_openai_responses_run_inference_client_exception():
"""Test that exceptions from the client are propagated."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService()
service._client = AsyncMock()
context = LLMContext(messages=[{"role": "user", "content": "Hello"}])
service._client.responses.create = AsyncMock(side_effect=Exception("API Error"))
with pytest.raises(Exception, match="API Error"):
await service.run_inference(context)
@pytest.mark.asyncio
async def test_openai_responses_run_inference_system_instruction_overrides():
"""Test that system_instruction parameter overrides the settings instruction."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="Original instruction",
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context, system_instruction="New system instruction")
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["instructions"] == "New system instruction"
assert call_kwargs["input"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_openai_responses_run_inference_empty_context_with_instruction():
"""Test that system_instruction becomes a developer message when context is empty."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
system_instruction="You are helpful",
),
)
service._client = AsyncMock()
context = LLMContext(messages=[])
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context)
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
# With empty context, instruction should become a developer message
assert call_kwargs["input"] == [{"role": "developer", "content": "You are helpful"}]
assert "instructions" not in call_kwargs
@pytest.mark.asyncio
async def test_openai_responses_run_inference_max_tokens_override():
"""Test that max_tokens parameter overrides max_output_tokens."""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(
model="gpt-4.1",
max_completion_tokens=500,
),
)
service._client = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Summarize this"}],
)
mock_response = MagicMock()
mock_response.output_text = "Summary"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(context, max_tokens=200)
assert result == "Summary"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["max_output_tokens"] == 200
@pytest.mark.asyncio
async def test_openai_responses_run_inference_system_instruction_param_with_empty_context():
"""Test that system_instruction param becomes a developer message when context is empty.
The Responses API rejects requests with instructions but no input items.
When run_inference is called with an explicit system_instruction and an
empty context, the instruction must become a developer message — not be
sent as the instructions parameter.
"""
with patch.object(OpenAIResponsesLLMService, "_create_client"):
service = OpenAIResponsesLLMService(
settings=OpenAIResponsesLLMService.Settings(model="gpt-4.1"),
)
service._client = AsyncMock()
context = LLMContext(messages=[])
mock_response = MagicMock()
mock_response.output_text = "Response"
service._client.responses.create = AsyncMock(return_value=mock_response)
result = await service.run_inference(
context, system_instruction="Summarize the conversation"
)
assert result == "Response"
call_kwargs = service._client.responses.create.call_args.kwargs
assert call_kwargs["input"] == [
{"role": "developer", "content": "Summarize the conversation"}
]
assert "instructions" not in call_kwargs

153
tests/test_runner_utils.py Normal file
View File

@@ -0,0 +1,153 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import json
import unittest
from unittest.mock import MagicMock
from pipecat.runner.utils import parse_telephony_websocket
class MockAsyncIterator:
"""Mock async iterator for WebSocket messages."""
def __init__(self, messages):
self.messages = messages
self.index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self.index >= len(self.messages):
raise StopAsyncIteration
message = self.messages[self.index]
self.index += 1
return message
class TestParseTelephonyWebSocket(unittest.IsolatedAsyncioTestCase):
async def test_no_messages_raises_value_error(self):
"""Test that no messages raises ValueError."""
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([])
with self.assertRaises(ValueError) as context:
await parse_telephony_websocket(mock_websocket)
self.assertIn("WebSocket closed before receiving", str(context.exception))
async def test_one_message_logs_warning_and_continues(self):
"""Test that one message logs warning but continues processing."""
twilio_message = json.dumps(
{
"event": "start",
"start": {
"streamSid": "MZ123",
"callSid": "CA123",
"customParameters": {"user_id": "test_user"},
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([twilio_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "twilio")
self.assertEqual(call_data["stream_id"], "MZ123")
self.assertEqual(call_data["call_id"], "CA123")
async def test_two_messages_normal_operation(self):
"""Test normal operation with two messages."""
first_message = json.dumps({"event": "connected"})
twilio_message = json.dumps(
{
"event": "start",
"start": {
"streamSid": "MZ456",
"callSid": "CA456",
"customParameters": {},
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([first_message, twilio_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "twilio")
self.assertEqual(call_data["stream_id"], "MZ456")
self.assertEqual(call_data["call_id"], "CA456")
async def test_telnyx_detection(self):
"""Test Telnyx provider detection."""
telnyx_message = json.dumps(
{
"stream_id": "stream_123",
"start": {
"call_control_id": "cc_123",
"media_format": {"encoding": "PCMU"},
"from": "+15551234567",
"to": "+15559876543",
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([telnyx_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "telnyx")
self.assertEqual(call_data["stream_id"], "stream_123")
self.assertEqual(call_data["call_control_id"], "cc_123")
async def test_plivo_detection(self):
"""Test Plivo provider detection."""
plivo_message = json.dumps(
{"start": {"streamId": "stream_plivo_123", "callId": "call_plivo_123"}}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([plivo_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "plivo")
self.assertEqual(call_data["stream_id"], "stream_plivo_123")
self.assertEqual(call_data["call_id"], "call_plivo_123")
async def test_exotel_detection(self):
"""Test Exotel provider detection."""
exotel_message = json.dumps(
{
"event": "start",
"start": {
"stream_sid": "stream_exo_123",
"call_sid": "call_exo_123",
"account_sid": "acc_123",
"from": "+15551111111",
"to": "+15552222222",
},
}
)
mock_websocket = MagicMock()
mock_websocket.iter_text.return_value = MockAsyncIterator([exotel_message])
transport_type, call_data = await parse_telephony_websocket(mock_websocket)
self.assertEqual(transport_type, "exotel")
self.assertEqual(call_data["stream_id"], "stream_exo_123")
self.assertEqual(call_data["call_id"], "call_exo_123")
self.assertEqual(call_data["account_sid"], "acc_123")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,72 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Unit tests for SambaNova LLM service."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.sambanova.llm import SambaNovaLLMService
@pytest.mark.asyncio
async def test_sambanova_llm_stream_closed_on_cancellation():
"""Test that the stream is closed when CancelledError occurs during iteration.
This prevents socket leaks when the pipeline is interrupted (e.g., user interruption).
See issue #3639.
"""
with patch.object(SambaNovaLLMService, "create_client"):
service = SambaNovaLLMService(api_key="test-key", model="test-model")
service._client = AsyncMock()
stream_closed = False
class MockAsyncStream:
"""Mock AsyncStream that tracks close() calls and raises CancelledError."""
def __init__(self):
self.iteration_count = 0
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
nonlocal stream_closed
stream_closed = True
return False
def __aiter__(self):
return self
async def __anext__(self):
self.iteration_count += 1
if self.iteration_count > 1:
raise asyncio.CancelledError()
mock_chunk = AsyncMock()
mock_chunk.usage = None
mock_chunk.choices = []
return mock_chunk
mock_stream = MockAsyncStream()
service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream)
service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream)
service.start_ttfb_metrics = AsyncMock()
service.stop_ttfb_metrics = AsyncMock()
service.start_llm_usage_metrics = AsyncMock()
context = LLMContext(
messages=[{"role": "user", "content": "Hello"}],
)
with pytest.raises(asyncio.CancelledError):
await service._process_context(context)
assert stream_closed, "Stream should be closed even when CancelledError occurs"

180
tests/test_service_init.py Normal file
View File

@@ -0,0 +1,180 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for service settings and initialization patterns.
Settings objects operate in two modes:
- **Store mode** (``self._settings``): the live state inside a service.
Every field must hold a real value (``None`` is fine, ``NOT_GIVEN`` is not).
- **Delta mode** (``FooSettings()`` with no args): a sparse update.
Every field must default to ``NOT_GIVEN`` so ``apply_update()`` skips
untouched fields and doesn't accidentally overwrite the store.
These tests verify both sides of that contract automatically:
1. **Delta defaults** — Instantiate every ``ServiceSettings`` subclass with
no arguments and assert that every field is ``NOT_GIVEN``. Catches the
bug where a field defaults to ``None`` instead of ``NOT_GIVEN``, which
would cause partial deltas to silently overwrite unrelated store values.
2. **Store completeness** — Instantiate every concrete service with dummy
args and assert that ``_settings`` contains no ``NOT_GIVEN`` values.
This is the same check that ``validate_complete()`` runs in ``start()``,
but caught here at unit-test time without needing a running pipeline.
Catches services that forget to initialize a field in ``default_settings``.
All Settings and Service classes are auto-discovered via ``pkgutil``;
new services are covered automatically with no per-service maintenance.
"""
import importlib
import inspect
import pkgutil
from dataclasses import fields
import pytest
import pipecat.services
from pipecat.services.ai_service import AIService
from pipecat.services.settings import ServiceSettings, is_given
# Modules that define abstract base service classes (not concrete services).
_BASE_MODULES = frozenset(
{
"pipecat.services.ai_service",
"pipecat.services.llm_service",
"pipecat.services.stt_service",
"pipecat.services.tts_service",
"pipecat.services.image_gen_service",
"pipecat.services.vision_service",
}
)
# ---------------------------------------------------------------------------
# Auto-discovery
# ---------------------------------------------------------------------------
def _all_subclasses(cls):
result = set()
for sub in cls.__subclasses__():
result.add(sub)
result.update(_all_subclasses(sub))
return result
def _import_all_service_modules():
"""Import every module under pipecat.services (skipping missing deps)."""
package = pipecat.services
for _importer, modname, _ispkg in pkgutil.walk_packages(
package.__path__, prefix=package.__name__ + ".", onerror=lambda _name: None
):
try:
importlib.import_module(modname)
except Exception:
continue
_import_all_service_modules()
ALL_SETTINGS_CLASSES = sorted(_all_subclasses(ServiceSettings), key=lambda c: c.__qualname__)
assert ALL_SETTINGS_CLASSES, "No settings classes discovered"
# ---------------------------------------------------------------------------
# Service instantiation helpers
# ---------------------------------------------------------------------------
def _try_instantiate(cls):
"""Try to instantiate a service with dummy values for required args.
Inspects the __init__ signature and passes "test" for every required
keyword-only parameter. Services that need non-string required args
or fail for other reasons will raise and be skipped by the test.
"""
sig = inspect.signature(cls.__init__)
kwargs = {}
for name, param in sig.parameters.items():
if name == "self":
continue
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if param.default is not param.empty:
continue
# Required parameter — pass a dummy string
kwargs[name] = "test"
return cls(**kwargs)
def _discover_service_classes():
"""Return concrete service classes that can be instantiated with dummy args."""
result = []
for cls in sorted(_all_subclasses(AIService), key=lambda c: c.__qualname__):
# Skip abstract base classes defined in framework modules.
if cls.__module__ in _BASE_MODULES:
continue
try:
svc = _try_instantiate(cls)
except Exception:
continue
if hasattr(svc, "_settings"):
result.append(cls)
return result
ALL_SERVICE_CLASSES = _discover_service_classes()
assert ALL_SERVICE_CLASSES, "No service classes could be instantiated"
# ---------------------------------------------------------------------------
# 1. Settings defaults: delta-mode safety
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("settings_cls", ALL_SETTINGS_CLASSES, ids=lambda c: c.__qualname__)
def test_delta_defaults_are_not_given(settings_cls):
"""Every field must default to NOT_GIVEN so empty deltas are no-ops.
A field that defaults to None instead of NOT_GIVEN will cause
apply_update() to overwrite the corresponding store value whenever
a partial delta is applied.
"""
instance = settings_cls()
for f in fields(instance):
if f.name == "extra":
continue
val = getattr(instance, f.name)
assert not is_given(val), (
f"{settings_cls.__qualname__}.{f.name} defaults to {val!r}, expected NOT_GIVEN"
)
# ---------------------------------------------------------------------------
# 2. Service construction: store-mode completeness
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("service_cls", ALL_SERVICE_CLASSES, ids=lambda c: c.__qualname__)
def test_service_settings_complete(service_cls):
"""After construction, _settings must have no NOT_GIVEN values.
This is what validate_complete() checks in start(). Catching it
here means we don't need a running pipeline to find missing defaults.
"""
try:
svc = _try_instantiate(service_cls)
except Exception:
pytest.skip("Cannot re-instantiate (environment issue)")
for f in fields(svc._settings):
if f.name == "extra":
continue
val = getattr(svc._settings, f.name)
assert is_given(val), (
f"{service_cls.__qualname__}._settings.{f.name} is NOT_GIVEN after construction"
)

View File

@@ -0,0 +1,251 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for language parameter handling in TTS and STT services.
Verifies that Language enums, raw strings (e.g. "de-DE"), and unrecognized
strings are all resolved correctly at both init time and runtime update time.
"""
from typing import AsyncGenerator, Optional
from unittest.mock import patch
import pytest
from pipecat.frames.frames import Frame
from pipecat.services.settings import STTSettings, TTSSettings
from pipecat.services.stt_service import STTService
from pipecat.services.tts_service import TTSService
from pipecat.transcriptions.language import Language, resolve_language
# ---------------------------------------------------------------------------
# Minimal concrete subclasses for testing
# ---------------------------------------------------------------------------
# A simple language map using only base codes (like ElevenLabs does).
_LANGUAGE_MAP = {
Language.DE: "de",
Language.EN: "en",
Language.FR: "fr",
}
class _TestTTSService(TTSService):
"""Minimal concrete TTS service for testing language resolution."""
class Settings(TTSSettings):
pass
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
yield # pragma: no cover
def language_to_service_language(self, language: Language) -> Optional[str]:
return resolve_language(language, _LANGUAGE_MAP, use_base_code=True)
class _TestSTTService(STTService):
"""Minimal concrete STT service for testing language resolution."""
class Settings(STTSettings):
pass
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
yield # pragma: no cover
async def process_audio_frame(self, frame, direction):
pass # pragma: no cover
def language_to_service_language(self, language: Language) -> Optional[str]:
return resolve_language(language, _LANGUAGE_MAP, use_base_code=True)
# ---------------------------------------------------------------------------
# TTS init tests
# ---------------------------------------------------------------------------
class TestTTSLanguageInit:
"""Test language resolution at TTS service init time."""
def test_language_enum_base_code(self):
"""Language.DE (base code in map) resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE))
assert svc._settings.language == "de"
def test_language_enum_regional_code(self):
"""Language.DE_DE (regional, not in map) falls back to base code 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE_DE))
assert svc._settings.language == "de"
def test_raw_string_base_code(self):
"""Raw string 'de' is converted to Language.DE then resolved to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="de"))
assert svc._settings.language == "de"
def test_raw_string_regional_code(self):
"""Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="de-DE"))
assert svc._settings.language == "de"
def test_raw_string_other_regional(self):
"""Raw string 'en-US' is converted to Language.EN_US then resolved to 'en'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language="en-US"))
assert svc._settings.language == "en"
def test_raw_string_unrecognized(self):
"""Unrecognized raw string logs a warning and is passed through as-is."""
with patch("pipecat.services.tts_service.logger") as mock_logger:
svc = _TestTTSService(settings=_TestTTSService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.warning.assert_called_once()
assert "klingon" in mock_logger.warning.call_args[0][0]
def test_language_none(self):
"""None language is left as None."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
assert svc._settings.language is None
# ---------------------------------------------------------------------------
# STT init tests
# ---------------------------------------------------------------------------
class TestSTTLanguageInit:
"""Test language resolution at STT service init time."""
def test_language_enum_base_code(self):
"""Language.FR (base code in map) resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR))
assert svc._settings.language == "fr"
def test_language_enum_regional_code(self):
"""Language.FR_FR (regional, not in map) falls back to base code 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR_FR))
assert svc._settings.language == "fr"
def test_raw_string_base_code(self):
"""Raw string 'fr' is converted to Language.FR then resolved to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language="fr"))
assert svc._settings.language == "fr"
def test_raw_string_regional_code(self):
"""Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language="de-DE"))
assert svc._settings.language == "de"
def test_raw_string_unrecognized(self):
"""Unrecognized raw string logs a warning and is passed through as-is."""
with patch("pipecat.services.stt_service.logger") as mock_logger:
svc = _TestSTTService(settings=_TestSTTService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.warning.assert_called_once()
assert "klingon" in mock_logger.warning.call_args[0][0]
def test_language_none(self):
"""None language is left as None."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
assert svc._settings.language is None
# ---------------------------------------------------------------------------
# TTS runtime update tests
# ---------------------------------------------------------------------------
class TestTTSLanguageUpdate:
"""Test language resolution during runtime settings updates."""
@pytest.mark.asyncio
async def test_update_language_enum_base_code(self):
"""Updating with Language.EN resolves to 'en'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language=Language.EN))
assert svc._settings.language == "en"
@pytest.mark.asyncio
async def test_update_language_enum_regional_code(self):
"""Updating with Language.DE_DE falls back to base code 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language=Language.DE_DE))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_base_code(self):
"""Updating with raw string 'de' resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language="de"))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_regional_code(self):
"""Updating with raw string 'de-DE' resolves to 'de'."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
await svc._update_settings(_TestTTSService.Settings(language="de-DE"))
assert svc._settings.language == "de"
@pytest.mark.asyncio
async def test_update_raw_string_unrecognized(self):
"""Updating with unrecognized string logs warning and passes through."""
svc = _TestTTSService(settings=_TestTTSService.Settings(language=None))
with patch("pipecat.services.tts_service.logger") as mock_logger:
await svc._update_settings(_TestTTSService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.warning.assert_called_once()
assert "klingon" in mock_logger.warning.call_args[0][0]
# ---------------------------------------------------------------------------
# STT runtime update tests
# ---------------------------------------------------------------------------
class TestSTTLanguageUpdate:
"""Test language resolution during runtime settings updates."""
@pytest.mark.asyncio
async def test_update_language_enum_base_code(self):
"""Updating with Language.EN resolves to 'en'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language=Language.EN))
assert svc._settings.language == "en"
@pytest.mark.asyncio
async def test_update_language_enum_regional_code(self):
"""Updating with Language.FR_FR falls back to base code 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language=Language.FR_FR))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_base_code(self):
"""Updating with raw string 'fr' resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language="fr"))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_regional_code(self):
"""Updating with raw string 'fr-FR' resolves to 'fr'."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
await svc._update_settings(_TestSTTService.Settings(language="fr-FR"))
assert svc._settings.language == "fr"
@pytest.mark.asyncio
async def test_update_raw_string_unrecognized(self):
"""Updating with unrecognized string logs warning and passes through."""
svc = _TestSTTService(settings=_TestSTTService.Settings(language=None))
with patch("pipecat.services.stt_service.logger") as mock_logger:
await svc._update_settings(_TestSTTService.Settings(language="klingon"))
assert svc._settings.language == "klingon"
mock_logger.warning.assert_called_once()
assert "klingon" in mock_logger.warning.call_args[0][0]

View File

@@ -6,17 +6,27 @@
"""Unit tests for ServiceSwitcher and related components."""
import asyncio
import unittest
from dataclasses import dataclass
from pipecat.frames.frames import (
ErrorFrame,
Frame,
ManuallySwitchServiceFrame,
ServiceMetadataFrame,
ServiceSwitcherRequestMetadataFrame,
StartFrame,
SystemFrame,
TextFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.service_switcher import ServiceSwitcher, ServiceSwitcherStrategyManual
from pipecat.pipeline.service_switcher import (
ServiceSwitcher,
ServiceSwitcherStrategy,
ServiceSwitcherStrategyFailover,
ServiceSwitcherStrategyManual,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@@ -54,6 +64,47 @@ class MockFrameProcessor(FrameProcessor):
self.frame_count = 0
@dataclass
class MockMetadataFrame(ServiceMetadataFrame):
"""A mock metadata frame for testing ServiceMetadataFrame handling."""
pass
class MockMetadataService(FrameProcessor):
"""A mock service that emits ServiceMetadataFrame like STT services.
Pushes MockMetadataFrame on StartFrame and ServiceSwitcherRequestMetadataFrame.
"""
def __init__(self, test_name: str, **kwargs):
super().__init__(name=test_name, **kwargs)
self.test_name = test_name
self.processed_frames = []
self.metadata_push_count = 0
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
self.processed_frames.append(frame)
if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
await self._push_metadata()
elif isinstance(frame, ServiceSwitcherRequestMetadataFrame):
await self._push_metadata()
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
async def _push_metadata(self):
self.metadata_push_count += 1
await self.push_frame(MockMetadataFrame(service_name=self.test_name))
def reset_counters(self):
self.processed_frames = []
self.metadata_push_count = 0
@dataclass
class DummySystemFrame(SystemFrame):
"""A dummy system frame for testing purposes."""
@@ -61,6 +112,52 @@ class DummySystemFrame(SystemFrame):
text: str = ""
class TestServiceSwitcherStrategy(unittest.IsolatedAsyncioTestCase):
"""Test cases for the base ServiceSwitcherStrategy."""
def setUp(self):
"""Set up test fixtures."""
self.service1 = MockFrameProcessor("service1")
self.service2 = MockFrameProcessor("service2")
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_services(self):
"""Test initialization with a list of services."""
strategy = ServiceSwitcherStrategy(self.services)
self.assertEqual(strategy.services, self.services)
self.assertEqual(strategy.active_service, self.service1)
async def test_handle_frame_returns_none_for_manual_switch(self):
"""Test that base strategy does not handle ManuallySwitchServiceFrame."""
strategy = ServiceSwitcherStrategy(self.services)
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertIsNone(result)
self.assertEqual(strategy.active_service, self.service1)
async def test_handle_frame_returns_none_for_unsupported_frame(self):
"""Test that unsupported frame types return None."""
strategy = ServiceSwitcherStrategy(self.services)
unsupported_frame = TextFrame(text="test")
result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
self.assertIsNone(result)
async def test_handle_error_returns_none(self):
"""Test that handle_error returns None by default."""
strategy = ServiceSwitcherStrategy(self.services)
result = await strategy.handle_error(ErrorFrame(error="error"))
self.assertIsNone(result)
self.assertEqual(strategy.active_service, self.service1)
class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceSwitcherStrategyManual."""
@@ -71,53 +168,64 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_services(self):
"""Test initialization with a list of services."""
def test_is_subclass_of_base_strategy(self):
"""Test that ServiceSwitcherStrategyManual is a subclass of ServiceSwitcherStrategy."""
strategy = ServiceSwitcherStrategyManual(self.services)
self.assertIsInstance(strategy, ServiceSwitcherStrategy)
self.assertEqual(strategy.services, self.services)
self.assertEqual(strategy.active_service, self.service1) # First service should be active
def test_init_with_empty_services(self):
"""Test initialization with an empty list of services."""
strategy = ServiceSwitcherStrategyManual([])
self.assertEqual(strategy.services, [])
self.assertIsNone(strategy.active_service)
def test_handle_manually_switch_service_frame(self):
async def test_handle_manually_switch_service_frame(self):
"""Test manual service switching with ManuallySwitchServiceFrame."""
strategy = ServiceSwitcherStrategyManual(self.services)
# Initially service1 should be active
self.assertEqual(strategy.active_service, self.service1)
self.assertNotEqual(strategy.active_service, self.service2)
# Switch to service2
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertNotEqual(strategy.active_service, self.service1)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(strategy.active_service, self.service2)
self.assertNotEqual(strategy.active_service, self.service3)
# Switch to service3
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertNotEqual(strategy.active_service, self.service1)
self.assertNotEqual(strategy.active_service, self.service2)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(strategy.active_service, self.service3)
def test_handle_frame_unsupported_frame_type(self):
"""Test that unsupported frame types raise an error."""
async def test_on_service_switched_event(self):
"""Test that on_service_switched event fires with correct arguments."""
strategy = ServiceSwitcherStrategyManual(self.services)
unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame
with self.assertRaises(ValueError) as context:
strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
switched_events = []
self.assertIn("Unsupported frame type", str(context.exception))
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append((strategy, service))
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
await asyncio.sleep(0)
self.assertEqual(len(switched_events), 1)
self.assertIsInstance(switched_events[0][0], ServiceSwitcherStrategyManual)
self.assertEqual(switched_events[0][1], self.service2)
async def test_unknown_service_ignored(self):
"""Test that switching to an unknown service is ignored."""
strategy = ServiceSwitcherStrategyManual(self.services)
switched_events = []
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append(service)
unknown_service = MockFrameProcessor("unknown")
switch_frame = ManuallySwitchServiceFrame(service=unknown_service)
result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
await asyncio.sleep(0)
self.assertIsNone(result)
self.assertEqual(len(switched_events), 0)
self.assertEqual(strategy.active_service, self.service1)
class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
@@ -130,9 +238,9 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_with_manual_strategy(self):
"""Test initialization with manual strategy."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
def test_init_with_default_strategy(self):
"""Test initialization with default strategy."""
switcher = ServiceSwitcher(self.services)
self.assertEqual(switcher.services, self.services)
self.assertIsInstance(switcher.strategy, ServiceSwitcherStrategyManual)
@@ -140,7 +248,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
async def test_default_active_service(self):
"""Test that the initially-active service receives frames while others don't."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
switcher = ServiceSwitcher(self.services)
# Reset counters
for service in self.services:
@@ -209,7 +317,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
async def test_service_switching(self):
"""Test that after service switching using ManuallySwitchServiceFrame, the new active service receives frames while others don't."""
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
switcher = ServiceSwitcher(self.services)
# Reset counters
for service in self.services:
@@ -223,7 +331,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
ManuallySwitchServiceFrame(service=self.service2),
TextFrame("Hello 2"),
],
expected_down_frames=[TextFrame, ManuallySwitchServiceFrame, TextFrame],
expected_down_frames=[TextFrame, TextFrame],
expected_up_frames=[], # Expect no error frames
)
@@ -258,8 +366,8 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
switcher2_services = [switcher2_service1, switcher2_service2]
# Create two service switchers
switcher1 = ServiceSwitcher(switcher1_services, ServiceSwitcherStrategyManual)
switcher2 = ServiceSwitcher(switcher2_services, ServiceSwitcherStrategyManual)
switcher1 = ServiceSwitcher(switcher1_services)
switcher2 = ServiceSwitcher(switcher2_services)
# Create a pipeline with both switchers: switcher1 -> switcher2
pipeline = Pipeline([switcher1, switcher2])
@@ -289,9 +397,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
],
expected_down_frames=[
TextFrame,
ManuallySwitchServiceFrame,
TextFrame,
ManuallySwitchServiceFrame,
TextFrame,
],
expected_up_frames=[], # Expect no error frames
@@ -336,5 +442,160 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
self.assertEqual(switcher2_service2_texts[0].text, "After switching second switcher")
class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceMetadataFrame handling in ServiceSwitcher."""
def setUp(self):
"""Set up test fixtures with mock metadata services."""
self.service1 = MockMetadataService("service1")
self.service2 = MockMetadataService("service2")
self.services = [self.service1, self.service2]
async def test_only_active_service_metadata_at_startup(self):
"""Test that only the active service's metadata leaves the ServiceSwitcher at startup."""
switcher = ServiceSwitcher(self.services)
# Run the pipeline (StartFrame triggers metadata emission)
output_frames = []
async def capture_frame(frame: Frame):
output_frames.append(frame)
await run_test(
switcher,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[MockMetadataFrame, TextFrame],
expected_up_frames=[],
)
# Both services push metadata internally on StartFrame, but only the
# active service's metadata passes through the filter
self.assertEqual(self.service1.metadata_push_count, 1) # StartFrame (passes filter)
self.assertEqual(self.service2.metadata_push_count, 1) # StartFrame (blocked by filter)
async def test_metadata_emitted_on_service_switch(self):
"""Test that switching services triggers metadata emission from the new active service."""
switcher = ServiceSwitcher(self.services)
# Reset counters after startup
self.service1.reset_counters()
self.service2.reset_counters()
await run_test(
switcher,
frames_to_send=[
TextFrame(text="before switch"),
ManuallySwitchServiceFrame(service=self.service2),
TextFrame(text="after switch"),
],
expected_down_frames=[
MockMetadataFrame, # From startup (service1)
TextFrame,
MockMetadataFrame, # From service2 after switch
TextFrame,
],
expected_up_frames=[],
)
# service2 should have received ServiceSwitcherRequestMetadataFrame after becoming active
request_frames = [
f
for f in self.service2.processed_frames
if isinstance(f, ServiceSwitcherRequestMetadataFrame)
]
self.assertEqual(len(request_frames), 1)
async def test_inactive_service_metadata_blocked(self):
"""Test that metadata from inactive services is blocked."""
switcher = ServiceSwitcher(self.services)
# Run and collect output frames
await run_test(
switcher,
frames_to_send=[TextFrame(text="test")],
expected_down_frames=[MockMetadataFrame, TextFrame],
expected_up_frames=[],
)
# service2 pushed metadata on StartFrame, but it should have been blocked
self.assertGreaterEqual(self.service2.metadata_push_count, 1)
# Only one MockMetadataFrame should have left (from service1)
class TestServiceSwitcherStrategyFailover(unittest.IsolatedAsyncioTestCase):
"""Test cases for ServiceSwitcherStrategyFailover."""
def setUp(self):
"""Set up test fixtures."""
self.service1 = MockFrameProcessor("service1")
self.service2 = MockFrameProcessor("service2")
self.service3 = MockFrameProcessor("service3")
self.services = [self.service1, self.service2, self.service3]
def test_init_defaults(self):
"""Test that default values are set correctly."""
strategy = ServiceSwitcherStrategyFailover(self.services)
self.assertEqual(strategy.active_service, self.service1)
async def test_error_switches_to_next_service(self):
"""Test that an error on the active service switches to the next one."""
strategy = ServiceSwitcherStrategyFailover(self.services)
error = ErrorFrame(error="connection lost")
result = await strategy.handle_error(error)
self.assertEqual(result, self.service2)
self.assertEqual(strategy.active_service, self.service2)
async def test_consecutive_errors_cycle_through_services(self):
"""Test that repeated errors cycle through all services."""
strategy = ServiceSwitcherStrategyFailover(self.services)
# First error: service1 -> service2
await strategy.handle_error(ErrorFrame(error="error 1"))
self.assertEqual(strategy.active_service, self.service2)
# Second error: service2 -> service3
await strategy.handle_error(ErrorFrame(error="error 2"))
self.assertEqual(strategy.active_service, self.service3)
# Third error: service3 -> service1 (wraps around)
await strategy.handle_error(ErrorFrame(error="error 3"))
self.assertEqual(strategy.active_service, self.service1)
async def test_single_service_returns_none(self):
"""Test that handle_error returns None with only one service."""
strategy = ServiceSwitcherStrategyFailover([self.service1])
result = await strategy.handle_error(ErrorFrame(error="error"))
self.assertIsNone(result)
async def test_manual_switch_still_works(self):
"""Test that ManuallySwitchServiceFrame is still handled."""
strategy = ServiceSwitcherStrategyFailover(self.services)
frame = ManuallySwitchServiceFrame(service=self.service3)
result = await strategy.handle_frame(frame, FrameDirection.DOWNSTREAM)
self.assertEqual(result, self.service3)
self.assertEqual(strategy.active_service, self.service3)
async def test_on_service_switched_event_fires_on_error(self):
"""Test that on_service_switched event fires when an error triggers a switch."""
strategy = ServiceSwitcherStrategyFailover(self.services)
switched_events = []
@strategy.event_handler("on_service_switched")
async def on_service_switched(strategy, service):
switched_events.append(service)
await strategy.handle_error(ErrorFrame(error="error"))
await asyncio.sleep(0)
self.assertEqual(len(switched_events), 1)
self.assertEqual(switched_events[0], self.service2)
if __name__ == "__main__":
unittest.main()

983
tests/test_settings.py Normal file
View File

@@ -0,0 +1,983 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for the typed settings infrastructure in pipecat.services.settings."""
from unittest.mock import patch
from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings
from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTSettings
from pipecat.services.grok.realtime import events as grok_events
from pipecat.services.grok.realtime.llm import GrokRealtimeLLMSettings
from pipecat.services.openai.realtime import events
from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMSettings
from pipecat.services.settings import (
NOT_GIVEN,
LLMSettings,
ServiceSettings,
STTSettings,
TTSSettings,
_NotGiven,
is_given,
)
# ---------------------------------------------------------------------------
# NOT_GIVEN sentinel
# ---------------------------------------------------------------------------
class TestNotGiven:
def test_singleton(self):
"""NOT_GIVEN is a singleton — every reference is the same object."""
assert _NotGiven() is _NotGiven()
assert NOT_GIVEN is _NotGiven()
def test_repr(self):
assert repr(NOT_GIVEN) == "NOT_GIVEN"
def test_bool_is_false(self):
assert not NOT_GIVEN
assert bool(NOT_GIVEN) is False
def test_is_given_with_not_given(self):
assert is_given(NOT_GIVEN) is False
def test_is_given_with_none(self):
assert is_given(None) is True
def test_is_given_with_values(self):
assert is_given(0) is True
assert is_given("") is True
assert is_given(False) is True
assert is_given(42) is True
assert is_given("hello") is True
# ---------------------------------------------------------------------------
# ServiceSettings base
# ---------------------------------------------------------------------------
class TestServiceSettings:
def test_default_fields_are_not_given(self):
s = ServiceSettings()
assert not is_given(s.model)
assert s.extra == {}
def test_given_fields_empty_by_default(self):
s = ServiceSettings()
assert s.given_fields() == {}
def test_given_fields_includes_set_values(self):
s = ServiceSettings(model="gpt-4o")
assert s.given_fields() == {"model": "gpt-4o"}
def test_given_fields_includes_extra(self):
s = ServiceSettings(model="gpt-4o")
s.extra = {"custom_key": 42}
result = s.given_fields()
assert result == {"model": "gpt-4o", "custom_key": 42}
def test_copy_is_deep(self):
s = ServiceSettings(model="gpt-4o")
s.extra = {"nested": {"a": 1}}
c = s.copy()
assert c.model == "gpt-4o"
assert c.extra == {"nested": {"a": 1}}
# Mutating the copy shouldn't affect the original
c.extra["nested"]["a"] = 999
assert s.extra["nested"]["a"] == 1
# ---------------------------------------------------------------------------
# apply_update
# ---------------------------------------------------------------------------
class TestApplyUpdate:
def test_apply_update_basic(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings(voice="bob")
changed = current.apply_update(delta)
assert changed.keys() == {"voice"}
assert changed["voice"] == "alice" # old value
assert current.voice == "bob"
assert current.language == "en"
def test_apply_update_no_change(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings(voice="alice")
changed = current.apply_update(delta)
assert changed == {}
assert current.voice == "alice"
def test_apply_update_not_given_skipped(self):
current = TTSSettings(voice="alice", language="en")
delta = TTSSettings() # all NOT_GIVEN
changed = current.apply_update(delta)
assert changed == {}
assert current.voice == "alice"
assert current.language == "en"
def test_apply_update_multiple_fields(self):
current = LLMSettings(temperature=0.7, max_tokens=100)
delta = LLMSettings(temperature=0.9, max_tokens=200, top_p=0.95)
changed = current.apply_update(delta)
assert changed.keys() == {"temperature", "max_tokens", "top_p"}
assert changed["temperature"] == 0.7
assert changed["max_tokens"] == 100
assert current.temperature == 0.9
assert current.max_tokens == 200
assert current.top_p == 0.95
def test_apply_update_extra_merged(self):
current = TTSSettings(voice="alice")
current.extra = {"speed": 1.0, "stability": 0.5}
delta = TTSSettings()
delta.extra = {"speed": 1.2}
changed = current.apply_update(delta)
assert "speed" in changed
assert changed["speed"] == 1.0 # old value
assert current.extra == {"speed": 1.2, "stability": 0.5}
def test_apply_update_extra_no_change(self):
current = TTSSettings(voice="alice")
current.extra = {"speed": 1.0}
delta = TTSSettings()
delta.extra = {"speed": 1.0}
changed = current.apply_update(delta)
assert changed == {}
def test_apply_update_model_field(self):
current = ServiceSettings(model="old-model")
delta = ServiceSettings(model="new-model")
changed = current.apply_update(delta)
assert changed.keys() == {"model"}
assert changed["model"] == "old-model"
assert current.model == "new-model"
def test_apply_update_none_is_a_valid_value(self):
"""Setting a field to None should be treated as a change from NOT_GIVEN."""
current = TTSSettings()
delta = TTSSettings(language=None)
changed = current.apply_update(delta)
assert "language" in changed
assert current.language is None
def test_apply_update_none_to_value(self):
current = TTSSettings(language=None)
delta = TTSSettings(language="en")
changed = current.apply_update(delta)
assert "language" in changed
assert changed["language"] is None # old value was None
assert current.language == "en"
# ---------------------------------------------------------------------------
# from_mapping
# ---------------------------------------------------------------------------
class TestFromMapping:
def test_basic_mapping(self):
s = TTSSettings.from_mapping({"voice": "alice", "language": "en"})
assert s.voice == "alice"
assert s.language == "en"
assert not is_given(s.model)
def test_alias_resolution(self):
"""'voice_id' is an alias for 'voice' in TTSSettings."""
s = TTSSettings.from_mapping({"voice_id": "alice"})
assert s.voice == "alice"
def test_unknown_keys_go_to_extra(self):
s = TTSSettings.from_mapping({"voice": "alice", "speed": 1.2, "stability": 0.5})
assert s.voice == "alice"
assert s.extra == {"speed": 1.2, "stability": 0.5}
def test_model_field(self):
s = LLMSettings.from_mapping({"model": "gpt-4o", "temperature": 0.7})
assert s.model == "gpt-4o"
assert s.temperature == 0.7
def test_empty_mapping(self):
s = ServiceSettings.from_mapping({})
assert s.given_fields() == {}
def test_all_unknown_keys(self):
s = ServiceSettings.from_mapping({"foo": 1, "bar": 2})
assert not is_given(s.model)
assert s.extra == {"foo": 1, "bar": 2}
def test_llm_settings_from_mapping(self):
s = LLMSettings.from_mapping({"temperature": 0.5, "max_tokens": 1000, "custom_param": True})
assert s.temperature == 0.5
assert s.max_tokens == 1000
assert s.extra == {"custom_param": True}
def test_stt_settings_from_mapping(self):
s = STTSettings.from_mapping({"language": "fr", "model": "whisper-large"})
assert s.language == "fr"
assert s.model == "whisper-large"
# ---------------------------------------------------------------------------
# LLMSettings specifics
# ---------------------------------------------------------------------------
class TestLLMSettings:
def test_all_fields_not_given_by_default(self):
s = LLMSettings()
for name in (
"model",
"temperature",
"max_tokens",
"top_p",
"top_k",
"frequency_penalty",
"presence_penalty",
"seed",
):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_given_fields(self):
s = LLMSettings(temperature=0.7, seed=42)
assert s.given_fields() == {"temperature": 0.7, "seed": 42}
# ---------------------------------------------------------------------------
# TTSSettings specifics
# ---------------------------------------------------------------------------
class TestTTSSettings:
def test_all_fields_not_given_by_default(self):
s = TTSSettings()
for name in ("model", "voice", "language"):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_aliases_class_var(self):
assert TTSSettings._aliases == {"voice_id": "voice"}
def test_given_fields(self):
s = TTSSettings(voice="alice")
assert s.given_fields() == {"voice": "alice"}
# ---------------------------------------------------------------------------
# STTSettings specifics
# ---------------------------------------------------------------------------
class TestSTTSettings:
def test_all_fields_not_given_by_default(self):
s = STTSettings()
for name in ("model", "language"):
assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN"
def test_given_fields(self):
s = STTSettings(language="en", model="whisper-large")
assert s.given_fields() == {"language": "en", "model": "whisper-large"}
# ---------------------------------------------------------------------------
# Integration: roundtrip from_mapping → apply_update
# ---------------------------------------------------------------------------
class TestRoundtrip:
def test_from_mapping_then_apply_update(self):
"""Simulate the real flow: dict arrives via frame, gets converted, applied."""
# Simulating current service state
current = TTSSettings(model="eleven_turbo_v2_5", voice="alice", language="en")
current.extra = {"stability": 0.5, "speed": 1.0}
# Incoming dict-based update
raw = {"voice_id": "bob", "speed": 1.2}
delta = TTSSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert changed.keys() == {"voice", "speed"}
assert changed["voice"] == "alice"
assert changed["speed"] == 1.0
assert current.voice == "bob"
assert current.language == "en"
assert current.extra["speed"] == 1.2
assert current.extra["stability"] == 0.5
def test_from_mapping_preserves_model(self):
current = LLMSettings(model="gpt-4o", temperature=0.7)
delta = LLMSettings.from_mapping({"model": "gpt-4o-mini", "temperature": 0.9})
changed = current.apply_update(delta)
assert changed.keys() == {"model", "temperature"}
assert changed["model"] == "gpt-4o"
assert current.model == "gpt-4o-mini"
assert current.temperature == 0.9
# ---------------------------------------------------------------------------
# DeepgramSTTSettings: flat field apply_update
# ---------------------------------------------------------------------------
class TestDeepgramSTTSettingsApplyUpdate:
def _make_store(self, **kwargs) -> DeepgramSTTSettings:
"""Helper to build a store-mode DeepgramSTTSettings."""
defaults = dict(
model="nova-3-general",
language="en",
interim_results=True,
smart_format=False,
punctuate=True,
profanity_filter=True,
vad_events=False,
)
defaults.update(kwargs)
return DeepgramSTTSettings(**defaults)
def test_apply_update_merges_flat_fields_as_delta(self):
"""Only the given fields in the delta are merged."""
current = self._make_store()
assert current.punctuate is True
delta = DeepgramSTTSettings(punctuate=False)
changed = current.apply_update(delta)
assert current.punctuate is False
assert "punctuate" in changed
# Other fields are untouched
assert current.model == "nova-3-general"
assert current.language == "en"
def test_apply_update_model(self):
"""model field is updated directly."""
current = self._make_store()
assert current.model == "nova-3-general"
delta = DeepgramSTTSettings(model="nova-2")
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert "model" in changed
def test_apply_update_language(self):
"""language field is updated directly."""
current = self._make_store()
assert current.language == "en"
delta = DeepgramSTTSettings(language="es")
changed = current.apply_update(delta)
assert current.language == "es"
assert "language" in changed
def test_apply_update_no_change(self):
"""Delta with same values should report no changes."""
current = self._make_store()
delta = DeepgramSTTSettings(punctuate=True)
changed = current.apply_update(delta)
assert changed == {}
def test_apply_update_multiple_fields(self):
"""Multiple flat fields updated at once."""
current = self._make_store()
delta = DeepgramSTTSettings(model="nova-2", language="fr", punctuate=False)
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert current.language == "fr"
assert current.punctuate is False
assert changed.keys() == {"model", "language", "punctuate"}
class TestDeepgramSTTSettingsFromMapping:
def test_known_flat_fields_mapped_directly(self):
"""Deepgram field names map directly to flat settings fields."""
delta = DeepgramSTTSettings.from_mapping({"punctuate": False, "diarize": True})
assert delta.punctuate is False
assert delta.diarize is True
def test_model_and_language_top_level(self):
"""model and language are top-level fields."""
delta = DeepgramSTTSettings.from_mapping({"model": "nova-2", "language": "es"})
assert delta.model == "nova-2"
assert delta.language == "es"
def test_unknown_keys_go_to_extra(self):
"""Keys that aren't declared fields go to extra."""
delta = DeepgramSTTSettings.from_mapping({"unknown_param": 42})
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + known Deepgram fields + unknown keys are routed correctly."""
delta = DeepgramSTTSettings.from_mapping(
{"model": "nova-2", "punctuate": False, "unknown": "val"}
)
assert delta.model == "nova-2"
assert delta.punctuate is False
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
current = DeepgramSTTSettings(
model="nova-3-general",
language="en",
interim_results=True,
punctuate=True,
profanity_filter=True,
vad_events=False,
)
raw = {"punctuate": False, "diarize": True}
delta = DeepgramSTTSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert current.punctuate is False
assert current.diarize is True
# Unchanged fields stay put
assert current.model == "nova-3-general"
assert "punctuate" in changed
def test_roundtrip_model_via_dict(self):
"""Dict update with model should change top-level model field."""
current = DeepgramSTTSettings(
model="nova-3-general",
language="en",
)
raw = {"model": "nova-2"}
delta = DeepgramSTTSettings.from_mapping(raw)
changed = current.apply_update(delta)
assert current.model == "nova-2"
assert "model" in changed
# ---------------------------------------------------------------------------
# DeepgramSageMakerSTTSettings: smoke test that flat base is inherited
# ---------------------------------------------------------------------------
class TestDeepgramSageMakerSTTSettings:
def test_inherits_flat_settings_behavior(self):
"""Smoke test: SageMaker settings inherit the flat base correctly."""
store = DeepgramSageMakerSTTSettings(
model="nova-3",
language="en",
)
delta = DeepgramSageMakerSTTSettings(model="nova-2")
changed = store.apply_update(delta)
assert store.model == "nova-2"
assert store.language == "en"
assert "model" in changed
# ---------------------------------------------------------------------------
# DeepgramSTTService: settings initialization with extra syncing
# ---------------------------------------------------------------------------
class TestDeepgramSTTSettingsExtraSync:
"""Test that settings.extra values are synced to declared fields at init time."""
def _make_service(self, **kwargs):
with patch("pipecat.services.deepgram.stt.AsyncDeepgramClient"):
return DeepgramSTTService(api_key="test-key", sample_rate=16000, **kwargs)
def test_extra_synced_to_declared_field_at_init(self):
"""LiveOptions params that match declared fields are synced at init."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(numerals=True)
svc = self._make_service(live_options=live_options)
# 'numerals' is a declared DeepgramSTTSettings field,
# so it should be promoted from extra to the declared field
assert svc._settings.numerals is True
assert "numerals" not in svc._settings.extra
def test_declared_field_from_live_options(self):
"""LiveOptions fields that match DeepgramSTTSettings fields are applied."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(
punctuate=False,
diarize=True,
)
svc = self._make_service(live_options=live_options)
# These should be in the declared fields
assert svc._settings.punctuate is False
assert svc._settings.diarize is True
def test_sync_after_from_mapping_with_extra(self):
"""If we use from_mapping with keys matching declared fields, they sync."""
# Simulate a dict-style update with both declared and undeclared keys
raw_dict = {
"diarize": True, # matches declared field
"punctuate": False, # matches declared field
"custom_param": "value", # doesn't match - stays in extra
}
delta = DeepgramSTTSettings.from_mapping(raw_dict)
# After from_mapping, declared fields should be set
assert delta.diarize is True
assert delta.punctuate is False
# Unknown stays in extra
assert delta.extra["custom_param"] == "value"
# Now simulate syncing (though from_mapping already routes correctly)
delta._sync_extra_to_fields()
# Still the same - from_mapping already put them in the right place
assert delta.diarize is True
assert delta.punctuate is False
assert delta.extra["custom_param"] == "value"
def test_sync_promotes_extra_to_field_when_not_given(self):
"""_sync_extra_to_fields promotes extra dict entries to declared fields."""
settings = DeepgramSTTSettings()
# Manually populate extra with a key matching a declared field
settings.extra = {"diarize": True, "punctuate": False, "unknown": "value"}
# Before sync, fields are NOT_GIVEN
assert not is_given(settings.diarize)
assert not is_given(settings.punctuate)
# Sync it
settings._sync_extra_to_fields()
# Now the matching fields should be promoted
assert settings.diarize is True
assert settings.punctuate is False
# And removed from extra
assert "diarize" not in settings.extra
assert "punctuate" not in settings.extra
# Unknown stays
assert settings.extra["unknown"] == "value"
def test_sync_doesnt_overwrite_already_set_field(self):
"""If a field is already set, extra shouldn't overwrite it."""
settings = DeepgramSTTSettings(punctuate=True)
# Try to put a different value in extra
settings.extra = {"punctuate": False}
# Sync
settings._sync_extra_to_fields()
# The already-set field should win
assert settings.punctuate is True
# extra entry should still be removed to avoid confusion
assert "punctuate" not in settings.extra
def test_build_connect_kwargs_after_sync(self):
"""After syncing, _build_connect_kwargs should use the right values."""
from pipecat.services.deepgram.stt import LiveOptions
live_options = LiveOptions(
model="nova-2",
language="es",
punctuate=True,
diarize=False,
)
svc = self._make_service(live_options=live_options)
kwargs = svc._build_connect_kwargs()
# All should appear in connect kwargs
assert kwargs["model"] == "nova-2"
assert kwargs["language"] == "es"
assert kwargs["punctuate"] == "true"
assert kwargs["diarize"] == "false"
def test_unknown_params_stay_in_extra_and_appear_in_kwargs(self):
"""Unknown params (not matching fields) stay in extra and get forwarded."""
from pipecat.services.deepgram.stt import LiveOptions
# 'numerals' is now a declared field; 'custom_param' is not
live_options = LiveOptions(numerals=True, custom_param="test")
svc = self._make_service(live_options=live_options)
# 'numerals' is a declared field, so it should be promoted
assert svc._settings.numerals is True
# 'custom_param' is unknown, so it stays in extra
assert svc._settings.extra["custom_param"] == "test"
# Both forwarded to kwargs
kwargs = svc._build_connect_kwargs()
assert kwargs["numerals"] == "true"
assert kwargs["custom_param"] == "test"
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: apply_update with bidirectional sync
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsApplyUpdate:
def _make_store(self, **kwargs) -> OpenAIRealtimeLLMSettings:
"""Helper to build a store-mode OpenAIRealtimeLLMSettings."""
defaults = dict(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
defaults.update(kwargs)
return OpenAIRealtimeLLMSettings(**defaults)
def test_top_level_model_syncs_to_sp(self):
"""Updating top-level model should propagate to session_properties.model."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(model="gpt-realtime-2.0")
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_top_level_system_instruction_syncs_to_sp(self):
"""Updating top-level system_instruction should propagate to session_properties.instructions."""
store = self._make_store()
delta = OpenAIRealtimeLLMSettings(system_instruction="Be helpful.")
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "Be helpful."
assert store.session_properties.instructions == "Be helpful."
def test_sp_replaces_wholesale(self):
"""session_properties in delta replaces the entire stored SP."""
store = self._make_store(
session_properties=events.SessionProperties(
output_modalities=["audio", "text"],
instructions="Old instructions.",
),
system_instruction="Old instructions.",
)
new_sp = events.SessionProperties(output_modalities=["text"])
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.output_modalities == ["text"]
# Fields not in the new SP become None (wholesale replacement)
# But model is synced from top-level
assert store.session_properties.model == "gpt-realtime-1.5"
def test_sp_model_syncs_to_top_level(self):
"""session_properties.model should sync to top-level model."""
store = self._make_store()
new_sp = events.SessionProperties(model="gpt-realtime-2.0")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "model" in changed
assert store.model == "gpt-realtime-2.0"
assert store.session_properties.model == "gpt-realtime-2.0"
def test_sp_instructions_syncs_to_top_level(self):
"""session_properties.instructions should sync to top-level system_instruction."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="New instructions.")
delta = OpenAIRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "New instructions."
assert store.session_properties.instructions == "New instructions."
def test_top_level_model_takes_precedence_over_sp_model(self):
"""When both model and session_properties.model are in the delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(model="sp-model")
delta = OpenAIRealtimeLLMSettings(model="top-model", session_properties=new_sp)
store.apply_update(delta)
assert store.model == "top-model"
assert store.session_properties.model == "top-model"
def test_top_level_si_takes_precedence_over_sp_instructions(self):
"""When both system_instruction and SP.instructions are in delta, top-level wins."""
store = self._make_store()
new_sp = events.SessionProperties(instructions="sp instructions")
delta = OpenAIRealtimeLLMSettings(
system_instruction="top instructions",
session_properties=new_sp,
)
store.apply_update(delta)
assert store.system_instruction == "top instructions"
assert store.session_properties.instructions == "top instructions"
def test_non_synced_field_update_does_not_affect_sp(self):
"""Updating a non-synced field like temperature shouldn't touch session_properties."""
store = self._make_store(
session_properties=events.SessionProperties(instructions="Keep me."),
system_instruction="Keep me.",
)
original_sp = store.session_properties
delta = OpenAIRealtimeLLMSettings(temperature=0.5)
changed = store.apply_update(delta)
assert "temperature" in changed
assert store.temperature == 0.5
# SP should be untouched (same object)
assert store.session_properties is original_sp
assert store.session_properties.instructions == "Keep me."
# ---------------------------------------------------------------------------
# OpenAIRealtimeLLMSettings: from_mapping
# ---------------------------------------------------------------------------
class TestOpenAIRealtimeSettingsFromMapping:
def test_sp_keys_route_to_session_properties(self):
"""SessionProperties fields (instructions, audio, etc.) route into nested SP."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{"instructions": "Be concise.", "output_modalities": ["text"]}
)
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be concise."
assert delta.session_properties.output_modalities == ["text"]
def test_model_routes_to_top_level(self):
"""model should go to the top-level field, not session_properties."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"model": "gpt-realtime-2.0"})
assert delta.model == "gpt-realtime-2.0"
# No session_properties should be created since no SP keys were present
assert not is_given(delta.session_properties)
def test_unknown_keys_go_to_extra(self):
"""Unrecognized keys should land in extra."""
delta = OpenAIRealtimeLLMSettings.from_mapping({"unknown_param": 42})
assert not is_given(delta.model)
assert not is_given(delta.session_properties)
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + SP keys + unknown keys are routed correctly."""
delta = OpenAIRealtimeLLMSettings.from_mapping(
{
"model": "gpt-realtime-2.0",
"instructions": "Be helpful.",
"unknown": "val",
}
)
assert delta.model == "gpt-realtime-2.0"
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be helpful."
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
store = OpenAIRealtimeLLMSettings(
model="gpt-realtime-1.5",
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=events.SessionProperties(),
)
raw = {"instructions": "Be concise.", "output_modalities": ["text"]}
delta = OpenAIRealtimeLLMSettings.from_mapping(raw)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.instructions == "Be concise."
assert store.session_properties.output_modalities == ["text"]
assert store.system_instruction == "Be concise."
# ---------------------------------------------------------------------------
# GrokRealtimeLLMSettings: apply_update
# ---------------------------------------------------------------------------
class TestGrokRealtimeSettingsApplyUpdate:
def _make_store(self, **kwargs) -> GrokRealtimeLLMSettings:
"""Helper to build a store-mode GrokRealtimeLLMSettings."""
defaults = dict(
model=None,
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=grok_events.SessionProperties(),
)
defaults.update(kwargs)
return GrokRealtimeLLMSettings(**defaults)
def test_top_level_system_instruction_syncs_to_sp(self):
"""Updating top-level system_instruction should propagate to session_properties.instructions."""
store = self._make_store()
delta = GrokRealtimeLLMSettings(system_instruction="Be helpful.")
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "Be helpful."
assert store.session_properties.instructions == "Be helpful."
def test_sp_replaces_wholesale(self):
"""session_properties in delta replaces the entire stored SP."""
store = self._make_store(
session_properties=grok_events.SessionProperties(
voice="Rex",
instructions="Old instructions.",
),
system_instruction="Old instructions.",
)
new_sp = grok_events.SessionProperties(voice="Sal")
delta = GrokRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.voice == "Sal"
# instructions is synced from top-level system_instruction
assert store.session_properties.instructions == "Old instructions."
def test_sp_instructions_syncs_to_top_level(self):
"""session_properties.instructions should sync to top-level system_instruction."""
store = self._make_store()
new_sp = grok_events.SessionProperties(instructions="New instructions.")
delta = GrokRealtimeLLMSettings(session_properties=new_sp)
changed = store.apply_update(delta)
assert "system_instruction" in changed
assert store.system_instruction == "New instructions."
assert store.session_properties.instructions == "New instructions."
def test_top_level_si_takes_precedence_over_sp_instructions(self):
"""When both system_instruction and SP.instructions are in delta, top-level wins."""
store = self._make_store()
new_sp = grok_events.SessionProperties(instructions="sp instructions")
delta = GrokRealtimeLLMSettings(
system_instruction="top instructions",
session_properties=new_sp,
)
store.apply_update(delta)
assert store.system_instruction == "top instructions"
assert store.session_properties.instructions == "top instructions"
def test_non_synced_field_update_does_not_affect_sp(self):
"""Updating a non-synced field like temperature shouldn't touch session_properties."""
store = self._make_store(
session_properties=grok_events.SessionProperties(instructions="Keep me."),
system_instruction="Keep me.",
)
original_sp = store.session_properties
delta = GrokRealtimeLLMSettings(temperature=0.5)
changed = store.apply_update(delta)
assert "temperature" in changed
assert store.temperature == 0.5
# SP should be untouched (same object)
assert store.session_properties is original_sp
assert store.session_properties.instructions == "Keep me."
# ---------------------------------------------------------------------------
# GrokRealtimeLLMSettings: from_mapping
# ---------------------------------------------------------------------------
class TestGrokRealtimeSettingsFromMapping:
def test_sp_keys_route_to_session_properties(self):
"""SessionProperties fields (instructions, voice, etc.) route into nested SP."""
delta = GrokRealtimeLLMSettings.from_mapping(
{"instructions": "Be concise.", "voice": "Rex"}
)
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be concise."
assert delta.session_properties.voice == "Rex"
def test_model_routes_to_top_level(self):
"""model should go to the top-level field, not session_properties."""
delta = GrokRealtimeLLMSettings.from_mapping({"model": "some-model"})
assert delta.model == "some-model"
# No session_properties should be created since no SP keys were present
assert not is_given(delta.session_properties)
def test_unknown_keys_go_to_extra(self):
"""Unrecognized keys should land in extra."""
delta = GrokRealtimeLLMSettings.from_mapping({"unknown_param": 42})
assert not is_given(delta.model)
assert not is_given(delta.session_properties)
assert delta.extra == {"unknown_param": 42}
def test_mixed_keys(self):
"""model + SP keys + unknown keys are routed correctly."""
delta = GrokRealtimeLLMSettings.from_mapping(
{
"model": "some-model",
"instructions": "Be helpful.",
"unknown": "val",
}
)
assert delta.model == "some-model"
assert is_given(delta.session_properties)
assert delta.session_properties.instructions == "Be helpful."
assert delta.extra == {"unknown": "val"}
def test_roundtrip_from_mapping_apply_update(self):
"""Simulate dict-style update: from_mapping -> apply_update."""
store = GrokRealtimeLLMSettings(
model=None,
system_instruction=None,
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=False,
user_turn_completion_config=None,
session_properties=grok_events.SessionProperties(),
)
raw = {"instructions": "Be concise.", "voice": "Eve"}
delta = GrokRealtimeLLMSettings.from_mapping(raw)
changed = store.apply_update(delta)
assert "session_properties" in changed
assert store.session_properties.instructions == "Be concise."
assert store.session_properties.voice == "Eve"
assert store.system_instruction == "Be concise."

View File

@@ -123,3 +123,97 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
# flush() returns any remaining text (the "W" in this case)
result = await self.aggregator.flush()
assert result.text == "W"
async def test_japanese_multiple_sentences(self):
"""Test that Japanese sentences are properly split during streaming."""
text = "こんにちは。元気ですか?"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence detected when 元 arrives as lookahead after 。
assert len(results) == 1
assert results[0].text == "こんにちは。"
# Flush returns the second sentence
result = await self.aggregator.flush()
assert result.text == "元気ですか?"
async def test_japanese_sentence_with_lookahead(self):
"""Test that a Japanese sentence is detected with a lookahead character."""
text = "こんにちは。元"
results = [agg async for agg in self.aggregator.aggregate(text)]
# 。 triggers lookahead, then 元 confirms it
assert len(results) == 1
assert results[0].text == "こんにちは。"
# Flush returns remainder
result = await self.aggregator.flush()
assert result.text == ""
async def test_chinese_streaming_tokens(self):
"""Test Chinese text split across multiple streaming tokens."""
aggregator = SimpleTextAggregator()
tokens = ["你好", "世界", "", "下一", "句话", ""]
all_results = []
for token in tokens:
results = [agg async for agg in aggregator.aggregate(token)]
all_results.extend(results)
# First sentence detected when 下 arrives after 。
assert len(all_results) == 1
assert all_results[0].text == "你好世界。"
# Flush returns the second sentence
result = await aggregator.flush()
assert result.text == "下一句话。"
async def test_japanese_single_sentence_flush(self):
"""Test that a single Japanese sentence with no lookahead flushes correctly."""
text = "こんにちは。"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No lookahead yet - waiting
assert len(results) == 0
# Flush returns the complete sentence
result = await self.aggregator.flush()
assert result.text == "こんにちは。"
class TestSimpleTextAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SimpleTextAggregator(aggregation_type=AggregationType.TOKEN)
async def test_token_passthrough(self):
"""TOKEN mode yields text immediately without buffering."""
results = [agg async for agg in self.aggregator.aggregate("Hello")]
assert len(results) == 1
assert results[0].text == "Hello"
assert results[0].type == "token"
async def test_token_multiple_calls(self):
"""Each aggregate call yields its text independently."""
r1 = [agg async for agg in self.aggregator.aggregate("Hello ")]
r2 = [agg async for agg in self.aggregator.aggregate("world.")]
assert len(r1) == 1
assert r1[0].text == "Hello "
assert len(r2) == 1
assert r2[0].text == "world."
async def test_token_empty_text(self):
"""Empty text yields nothing."""
results = [agg async for agg in self.aggregator.aggregate("")]
assert len(results) == 0
async def test_token_flush_returns_none(self):
"""Flush returns None in TOKEN mode since nothing is buffered."""
await self.aggregator.aggregate("Hello").__anext__()
result = await self.aggregator.flush()
assert result is None
if __name__ == "__main__":
unittest.main()

View File

@@ -62,3 +62,62 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.text, text)
self.assertEqual(self.aggregator.text.text, "")
self.assertEqual(self.aggregator.text.type, "sentence")
class TestSkipTagsAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SkipTagsAggregator(
[("<spell>", "</spell>")], aggregation_type=AggregationType.TOKEN
)
async def test_token_no_tags(self):
"""No tags: text passes through immediately as TOKEN."""
results = [agg async for agg in self.aggregator.aggregate("Hello!")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hello!")
self.assertEqual(results[0].type, "token")
async def test_token_inside_tag_buffers(self):
"""Inside a tag, text is buffered until the closing tag is found."""
results = [agg async for agg in self.aggregator.aggregate("<spell>foo@bar")]
# Still inside tag, nothing yielded
self.assertEqual(len(results), 0)
# Close the tag
results = [agg async for agg in self.aggregator.aggregate("</spell>")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "<spell>foo@bar</spell>")
self.assertEqual(results[0].type, "token")
async def test_token_flush_unclosed_tag(self):
"""Flush with unclosed tag returns remaining text."""
async for _ in self.aggregator.aggregate("<spell>unclosed"):
pass
result = await self.aggregator.flush()
# TOKEN mode flush returns None (parent behavior)
self.assertIsNone(result)
async def test_token_text_around_tags(self):
"""Simulate word-by-word token delivery with tags."""
results = []
# Simulate LLM streaming tokens one at a time
for token in ["Hi ", "<spell>", "X", "</spell>", " bye"]:
async for agg in self.aggregator.aggregate(token):
results.append(agg)
self.assertEqual(len(results), 3)
# Text before tag passes through immediately
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
# Tagged content is buffered until the closing tag, then yielded whole
self.assertEqual(results[1].text, "<spell>X</spell>")
self.assertEqual(results[1].type, "token")
# Text after tag passes through immediately
self.assertEqual(results[2].text, " bye")
self.assertEqual(results[2].type, "token")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,337 @@
import asyncio
import unittest
from pipecat.frames.frames import (
BotConnectedFrame,
ClientConnectedFrame,
Frame,
StartFrame,
TextFrame,
)
from pipecat.observers.startup_timing_observer import (
StartupTimingObserver,
StartupTimingReport,
TransportTimingReport,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
class SlowStartProcessor(FrameProcessor):
"""A processor that sleeps during start to simulate slow initialization."""
def __init__(self, delay: float = 0.1, **kwargs):
super().__init__(**kwargs)
self._delay = delay
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
await asyncio.sleep(self._delay)
await self.push_frame(frame, direction)
class FastProcessor(FrameProcessor):
"""A processor with no start delay."""
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
await self.push_frame(frame, direction)
class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for StartupTimingObserver."""
async def test_timing_reported(self):
"""Test that startup timing is measured and reported."""
observer = StartupTimingObserver()
processor = SlowStartProcessor(delay=0.1)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
self.assertGreater(report.total_duration_secs, 0)
self.assertGreater(len(report.processor_timings), 0)
# Find our slow processor in the timings.
slow_timings = [
t for t in report.processor_timings if "SlowStartProcessor" in t.processor_name
]
self.assertEqual(len(slow_timings), 1)
self.assertGreaterEqual(slow_timings[0].duration_secs, 0.05)
async def test_processor_types_filter(self):
"""Test that processor_types filter limits which processors appear."""
observer = StartupTimingObserver(processor_types=(SlowStartProcessor,))
processor = SlowStartProcessor(delay=0.05)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
# Only SlowStartProcessor should be in the timings.
for t in report.processor_timings:
self.assertIn("SlowStartProcessor", t.processor_name)
async def test_report_emits_once(self):
"""Test that the report is emitted only once even with multiple frames."""
observer = StartupTimingObserver()
processor = FastProcessor()
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [
TextFrame(text="first"),
TextFrame(text="second"),
TextFrame(text="third"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame, TextFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
async def test_event_handler_receives_report(self):
"""Test that the event handler receives a proper StartupTimingReport."""
observer = StartupTimingObserver()
processor = SlowStartProcessor(delay=0.05)
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
self.assertIsInstance(report, StartupTimingReport)
self.assertIsInstance(report.total_duration_secs, float)
self.assertGreater(report.start_time, 0)
for timing in report.processor_timings:
self.assertIsInstance(timing.processor_name, str)
self.assertIsInstance(timing.duration_secs, float)
self.assertGreaterEqual(timing.start_offset_secs, 0)
async def test_excludes_internal_processors(self):
"""Test that internal pipeline processors are excluded by default."""
observer = StartupTimingObserver()
processor = FastProcessor()
reports = []
@observer.event_handler("on_startup_timing_report")
async def on_report(obs, report):
reports.append(report)
frames_to_send = [TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[TextFrame],
observers=[observer],
)
self.assertEqual(len(reports), 1)
report = reports[0]
# No internal processors (PipelineSource, PipelineSink, Pipeline) in the report.
internal_names = ("Pipeline#", "PipelineTask#")
for t in report.processor_timings:
for prefix in internal_names:
self.assertNotIn(
prefix,
t.processor_name,
f"Internal processor {t.processor_name} should be excluded by default",
)
async def test_transport_timing_client_only(self):
"""Test that ClientConnectedFrame emits on_transport_timing_report."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
report = transport_reports[0]
self.assertIsInstance(report, TransportTimingReport)
self.assertGreater(report.start_time, 0)
self.assertGreater(report.client_connected_secs, 0)
self.assertIsNone(report.bot_connected_secs)
async def test_transport_timing_only_first_client(self):
"""Test that only the first ClientConnectedFrame triggers the event."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
ClientConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[ClientConnectedFrame, ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
async def test_transport_timing_without_start_frame(self):
"""Test that ClientConnectedFrame before StartFrame does not crash."""
observer = StartupTimingObserver()
# Directly call on_push_frame with a ClientConnectedFrame before any
# StartFrame has been seen. This should be a no-op (no crash).
from pipecat.observers.base_observer import FramePushed
processor = FastProcessor()
destination = FastProcessor()
data = FramePushed(
source=processor,
destination=destination,
frame=ClientConnectedFrame(),
direction=FrameDirection.DOWNSTREAM,
timestamp=1000,
)
await observer.on_push_frame(data)
# No event should have been emitted.
self.assertFalse(observer._transport_timing_reported)
async def test_bot_and_client_connected(self):
"""Test that BotConnectedFrame timing is included in the transport report."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
report = transport_reports[0]
self.assertGreater(report.client_connected_secs, 0)
self.assertIsNotNone(report.bot_connected_secs)
self.assertGreater(report.bot_connected_secs, 0)
# Client connected should be >= bot connected.
self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs)
async def test_bot_connected_only_first(self):
"""Test that only the first BotConnectedFrame is recorded."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[
BotConnectedFrame,
BotConnectedFrame,
ClientConnectedFrame,
TextFrame,
],
observers=[observer],
)
# Only one transport report, with bot timing from first frame.
self.assertEqual(len(transport_reports), 1)
self.assertIsNotNone(transport_reports[0].bot_connected_secs)
if __name__ == "__main__":
unittest.main()

View File

@@ -10,11 +10,11 @@ from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallFromLLM,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InputAudioRawFrame,
InterimTranscriptionFrame,
InterruptionFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
@@ -327,3 +327,28 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase):
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
async def test_interruption_frame_suppressed_when_muted(self):
"""Test that InterruptionFrame is suppressed when the filter is muted."""
filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.ALWAYS}))
frames_to_send = [
BotStartedSpeakingFrame(),
InterruptionFrame(),
BotStoppedSpeakingFrame(),
]
expected_returned_frames = [
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
filter,
frames_to_send=frames_to_send,
expected_down_frames=expected_returned_frames,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,117 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from dataclasses import dataclass
from pipecat.frames.frames import Frame, TextFrame
from pipecat.pipeline.sync_parallel_pipeline import FrameOrder, SyncParallelPipeline
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test
@dataclass
class TaggedFrame(Frame):
"""A simple tagged frame for testing pipeline ordering."""
tag: str = ""
def __str__(self):
return f"{self.name}(tag: {self.tag})"
class EmitTaggedFrameProcessor(FrameProcessor):
"""Emits a TaggedFrame for every TextFrame it receives.
Used to produce distinguishable output from different pipelines so tests
can verify ordering.
"""
def __init__(self, tag: str, *, delay: float = 0, **kwargs):
super().__init__(**kwargs)
self._tag = tag
self._delay = delay
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TextFrame):
if self._delay > 0:
await asyncio.sleep(self._delay)
await self.push_frame(TaggedFrame(tag=self._tag))
else:
await self.push_frame(frame, direction)
class TestSyncParallelPipeline(unittest.IsolatedAsyncioTestCase):
async def test_dedup_multiple_frames(self):
"""Identical frames from multiple paths should be deduplicated."""
pipeline = SyncParallelPipeline([IdentityFilter()], [IdentityFilter()])
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
expected_down_frames = [TextFrame, TextFrame]
await run_test(
pipeline,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
)
async def test_arrival_order(self):
"""With FrameOrder.ARRIVAL, a slow first pipeline's frames should
arrive after a fast second pipeline's frames."""
pipeline = SyncParallelPipeline(
[EmitTaggedFrameProcessor("slow", delay=0.05)],
[EmitTaggedFrameProcessor("fast")],
frame_order=FrameOrder.ARRIVAL,
)
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
)
tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)]
assert tags == [
"fast",
"slow",
"fast",
"slow",
], f"Expected fast before slow in each batch, got {tags}"
async def test_pipeline_order(self):
"""With FrameOrder.PIPELINE and multiple input frames, each batch
should follow pipeline definition order regardless of processing speed."""
pipeline = SyncParallelPipeline(
[EmitTaggedFrameProcessor("slow", delay=0.05)],
[EmitTaggedFrameProcessor("fast")],
frame_order=FrameOrder.PIPELINE,
)
frames_to_send = [TextFrame(text="one"), TextFrame(text="two")]
(down_frames, _) = await run_test(
pipeline,
frames_to_send=frames_to_send,
)
tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)]
assert tags == [
"slow",
"fast",
"slow",
"fast",
], f"Expected pipeline definition order (slow, fast) in each batch, got {tags}"
async def test_default_is_arrival(self):
"""The default frame_order should be ARRIVAL."""
pipeline = SyncParallelPipeline([IdentityFilter()])
assert pipeline._frame_order == FrameOrder.ARRIVAL
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,127 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
try:
from opentelemetry.sdk.trace import TracerProvider
HAS_OPENTELEMETRY = True
except ImportError:
HAS_OPENTELEMETRY = False
from pipecat.utils.tracing.tracing_context import TracingContext
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
class TestTracingContext(unittest.TestCase):
"""Tests for TracingContext."""
@classmethod
def setUpClass(cls):
"""Set up a tracer provider for generating span contexts."""
cls._provider = TracerProvider()
cls._tracer = cls._provider.get_tracer("test")
def test_initial_state_is_empty(self):
"""Test that a new TracingContext starts with no context set."""
ctx = TracingContext()
self.assertIsNone(ctx.get_conversation_context())
self.assertIsNone(ctx.get_turn_context())
self.assertIsNone(ctx.conversation_id)
def test_set_and_get_conversation_context(self):
"""Test setting and retrieving conversation context."""
ctx = TracingContext()
span = self._tracer.start_span("conv")
span_context = span.get_span_context()
ctx.set_conversation_context(span_context, "conv-123")
self.assertIsNotNone(ctx.get_conversation_context())
self.assertEqual(ctx.conversation_id, "conv-123")
span.end()
def test_clear_conversation_context(self):
"""Test clearing conversation context by passing None."""
ctx = TracingContext()
span = self._tracer.start_span("conv")
ctx.set_conversation_context(span.get_span_context(), "conv-123")
self.assertIsNotNone(ctx.get_conversation_context())
ctx.set_conversation_context(None)
self.assertIsNone(ctx.get_conversation_context())
self.assertIsNone(ctx.conversation_id)
span.end()
def test_set_and_get_turn_context(self):
"""Test setting and retrieving turn context."""
ctx = TracingContext()
span = self._tracer.start_span("turn")
span_context = span.get_span_context()
ctx.set_turn_context(span_context)
self.assertIsNotNone(ctx.get_turn_context())
span.end()
def test_clear_turn_context(self):
"""Test clearing turn context by passing None."""
ctx = TracingContext()
span = self._tracer.start_span("turn")
ctx.set_turn_context(span.get_span_context())
self.assertIsNotNone(ctx.get_turn_context())
ctx.set_turn_context(None)
self.assertIsNone(ctx.get_turn_context())
span.end()
def test_generate_conversation_id(self):
"""Test that generated conversation IDs are unique UUIDs."""
id1 = TracingContext.generate_conversation_id()
id2 = TracingContext.generate_conversation_id()
self.assertIsInstance(id1, str)
self.assertNotEqual(id1, id2)
def test_instances_are_isolated(self):
"""Test that two TracingContext instances do not share state."""
ctx_a = TracingContext()
ctx_b = TracingContext()
span = self._tracer.start_span("turn")
ctx_a.set_turn_context(span.get_span_context())
ctx_a.set_conversation_context(span.get_span_context(), "conv-a")
# ctx_b should still be empty
self.assertIsNone(ctx_b.get_turn_context())
self.assertIsNone(ctx_b.get_conversation_context())
self.assertIsNone(ctx_b.conversation_id)
span.end()
def test_conversation_and_turn_are_independent(self):
"""Test that clearing turn context does not affect conversation context."""
ctx = TracingContext()
conv_span = self._tracer.start_span("conv")
turn_span = self._tracer.start_span("turn")
ctx.set_conversation_context(conv_span.get_span_context(), "conv-1")
ctx.set_turn_context(turn_span.get_span_context())
# Clear turn but conversation should remain
ctx.set_turn_context(None)
self.assertIsNone(ctx.get_turn_context())
self.assertIsNotNone(ctx.get_conversation_context())
self.assertEqual(ctx.conversation_id, "conv-1")
conv_span.end()
turn_span.end()
if __name__ == "__main__":
unittest.main()

View File

@@ -792,3 +792,7 @@ class TestThoughtTranscription(unittest.IsolatedAsyncioTestCase):
# Verify no updates since thought wasn't properly started
self.assertEqual(len(received_updates), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,315 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for frame ordering across TTS service types.
Covers three patterns:
- HTTP TTS services (e.g. CartesiaHttpTTSService): yield audio frames synchronously.
- WebSocket TTS services without pause (e.g. CartesiaTTSService): deliver audio via
append_to_audio_context from a background receive loop, no frame-processing pause.
- WebSocket TTS services with pause (e.g. ElevenLabsTTSService): same delivery
mechanism, but pause downstream frame processing while audio is in flight.
For all three patterns we verify:
AggregatedTextFrame → TTSStartedFrame → TTSAudioRawFrame (1+) → TTSStoppedFrame → FooFrame
repeated for each TTSSpeakFrame, with no cross-group contamination.
"""
import asyncio
import unittest
from dataclasses import dataclass
from typing import AsyncGenerator, List, Sequence, Tuple
import pytest
from pipecat.frames.frames import (
AggregatedTextFrame,
DataFrame,
Frame,
TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame,
TTSStoppedFrame,
)
from pipecat.services.tts_service import TTSService
from pipecat.tests.utils import run_test
# ---------------------------------------------------------------------------
# Test-only frame
# ---------------------------------------------------------------------------
_FAKE_AUDIO = b"\x00\x01" * 320 # 320 bytes of silence
_SAMPLE_RATE = 16000
@dataclass
class FooFrame(DataFrame):
"""Marker frame used to verify relative ordering against TTS audio frames."""
label: str = ""
# ---------------------------------------------------------------------------
# Mock TTS services
# ---------------------------------------------------------------------------
class MockHttpTTSService(TTSService):
"""Simulates an HTTP TTS service (e.g. CartesiaHttpTTSService).
Audio frames are yielded synchronously from run_tts(), so the audio context
is fully populated before the next downstream frame is processed.
TTSStoppedFrame is appended by the base class in on_turn_context_completed()
once it detects _is_yielding_frames_synchronously is True.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_stop_frames=True,
push_text_frames=False,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
yield TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
)
class MockWebSocketTTSService(TTSService):
"""Simulates a WebSocket TTS service without frame-processing pause (e.g. CartesiaTTSService).
run_tts() is an empty async generator (signals async delivery). A background
task appends audio frames and the TTSStoppedFrame to the audio context after a
short delay, mimicking real WebSocket receive-loop behaviour.
pause_frame_processing=False means downstream frames (FooFrame) are NOT held.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_text_frames=False,
pause_frame_processing=False,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
async def _deliver_audio():
await asyncio.sleep(0.01)
await self.append_to_audio_context(
context_id,
TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
),
)
await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id))
await self.remove_audio_context(context_id)
self.create_task(_deliver_audio(), name=f"mock_ws_deliver_{context_id}")
if False:
yield # make this an async generator without yielding anything
class MockWebSocketPauseTTSService(TTSService):
"""Simulates a WebSocket TTS service WITH frame-processing pause (e.g. ElevenLabsTTSService).
Identical to MockWebSocketTTSService except pause_frame_processing=True.
on_audio_context_completed() resumes downstream processing once the full
audio context has been pushed, guaranteeing FooFrame arrives after TTSStoppedFrame.
"""
def __init__(self, **kwargs):
super().__init__(
push_start_frame=True,
push_text_frames=False,
pause_frame_processing=True,
sample_rate=_SAMPLE_RATE,
**kwargs,
)
def can_generate_metrics(self) -> bool:
return False
async def on_audio_context_completed(self, context_id: str):
# Resume frame processing after the audio context is fully played out.
await self._maybe_resume_frame_processing()
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
async def _deliver_audio():
await asyncio.sleep(0.01)
await self.append_to_audio_context(
context_id,
TTSAudioRawFrame(
audio=_FAKE_AUDIO,
sample_rate=_SAMPLE_RATE,
num_channels=1,
context_id=context_id,
),
)
await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id))
await self.remove_audio_context(context_id)
self.create_task(_deliver_audio(), name=f"mock_ws_pause_deliver_{context_id}")
if False:
yield
# ---------------------------------------------------------------------------
# Assertion helper
# ---------------------------------------------------------------------------
def _assert_group_ordering(
down_frames: Sequence[Frame],
expected_groups: List[Tuple[str, str]],
) -> None:
"""Assert two (or more) TTS+FooFrame groups are in strict order.
Args:
down_frames: All downstream frames received by the test sink.
expected_groups: List of (tts_text, foo_label) pairs, one per TTSSpeakFrame.
tts_text is unused in assertions today but included for readability.
"""
relevant = [
f
for f in down_frames
if isinstance(
f, (AggregatedTextFrame, TTSStartedFrame, TTSAudioRawFrame, TTSStoppedFrame, FooFrame)
)
]
# Locate the FooFrames that delimit groups.
foo_indices = [i for i, f in enumerate(relevant) if isinstance(f, FooFrame)]
assert len(foo_indices) == len(expected_groups), (
f"Expected {len(expected_groups)} FooFrames, got {len(foo_indices)}.\n"
f"Relevant frames: {[type(f).__name__ for f in relevant]}"
)
# Build groups: everything up to and including each FooFrame.
groups: List[List[Frame]] = []
prev = 0
for idx in foo_indices:
groups.append(relevant[prev : idx + 1])
prev = idx + 1
for group, (_, foo_label) in zip(groups, expected_groups):
types = [type(f) for f in group]
type_names = [t.__name__ for t in types]
assert AggregatedTextFrame in types, (
f"Group {foo_label!r}: missing AggregatedTextFrame. Got: {type_names}"
)
assert TTSStartedFrame in types, (
f"Group {foo_label!r}: missing TTSStartedFrame. Got: {type_names}"
)
assert TTSAudioRawFrame in types, (
f"Group {foo_label!r}: missing TTSAudioRawFrame. Got: {type_names}"
)
assert TTSStoppedFrame in types, (
f"Group {foo_label!r}: missing TTSStoppedFrame. Got: {type_names}"
)
started_idx = types.index(TTSStartedFrame)
stopped_idx = types.index(TTSStoppedFrame)
foo_idx = types.index(FooFrame)
assert started_idx < stopped_idx, (
f"Group {foo_label!r}: TTSStartedFrame (pos {started_idx}) must precede "
f"TTSStoppedFrame (pos {stopped_idx}). Got: {type_names}"
)
assert stopped_idx < foo_idx, (
f"Group {foo_label!r}: TTSStoppedFrame (pos {stopped_idx}) must precede "
f"FooFrame (pos {foo_idx}). Got: {type_names}"
)
# All frames between TTSStartedFrame and TTSStoppedFrame must be audio.
mid_types = types[started_idx + 1 : stopped_idx]
for t in mid_types:
assert t is TTSAudioRawFrame, (
f"Group {foo_label!r}: unexpected frame {t.__name__!r} between "
f"TTSStartedFrame and TTSStoppedFrame. Got: {type_names}"
)
# Check the FooFrame label.
actual_label = group[foo_idx].label
assert actual_label == foo_label, (
f"Expected FooFrame(label={foo_label!r}), got label={actual_label!r}"
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
_GROUPS = [("test 1", "1"), ("test 2", "2")]
def _make_frames_no_sleep() -> List[Frame]:
"""Return two TTSSpeakFrame+FooFrame pairs sent back-to-back.
Only correct for services that pause downstream processing until the audio
context is fully consumed (pause_frame_processing=True + on_audio_context_completed).
"""
return [
TTSSpeakFrame(text="test 1", append_to_context=False),
FooFrame(label="1"),
TTSSpeakFrame(text="test 2", append_to_context=False),
FooFrame(label="2"),
]
def _print_frames_received(frames_received) -> None:
print("FRAMES RECEIVED:")
for frame in frames_received[0]:
print(frame.name)
@pytest.mark.asyncio
async def test_http_tts_frame_ordering():
"""HTTP TTS services yield audio synchronously."""
tts = MockHttpTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
# only for debugging
_print_frames_received(frames_received)
_assert_group_ordering(frames_received[0], _GROUPS)
@pytest.mark.asyncio
async def test_websocket_tts_no_pause_frame_ordering():
"""WebSocket TTS services without pause_frame_processing."""
tts = MockWebSocketTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
_assert_group_ordering(frames_received[0], _GROUPS)
@pytest.mark.asyncio
async def test_websocket_tts_with_pause_frame_ordering():
"""WebSocket TTS services with pause_frame_processing=True."""
tts = MockWebSocketPauseTTSService()
frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep())
_assert_group_ordering(frames_received[0], _GROUPS)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,505 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import threading
import unittest
try:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
HAS_OPENTELEMETRY = True
except ImportError:
HAS_OPENTELEMETRY = False
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.utils.tracing.tracing_context import TracingContext
from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver
if HAS_OPENTELEMETRY:
class _InMemorySpanExporter(SpanExporter):
"""Simple in-memory span exporter for testing."""
def __init__(self):
"""Initialize the exporter."""
self._spans = []
self._lock = threading.Lock()
def export(self, spans):
"""Export spans to memory."""
with self._lock:
self._spans.extend(spans)
return SpanExportResult.SUCCESS
def get_finished_spans(self):
"""Return collected spans."""
with self._lock:
return list(self._spans)
def clear(self):
"""Clear collected spans."""
with self._lock:
self._spans.clear()
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
class TestTurnTraceObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for TurnTraceObserver."""
def setUp(self):
"""Set up a fresh provider and exporter for each test.
We create a dedicated TracerProvider per test and inject its tracer
directly into the observer, avoiding the global provider singleton.
"""
self._exporter = _InMemorySpanExporter()
self._provider = TracerProvider()
self._provider.add_span_processor(SimpleSpanProcessor(self._exporter))
self._tracer = self._provider.get_tracer("pipecat.turn")
def tearDown(self):
"""Shut down the provider to flush spans."""
self._provider.shutdown()
def _create_observers(self, conversation_id=None, tracing_context=None):
"""Create a standard set of turn/trace observers.
Args:
conversation_id: Optional conversation ID.
tracing_context: Optional TracingContext instance.
Returns:
Tuple of (turn_tracker, latency_tracker, trace_observer, tracing_context).
"""
tracing_context = tracing_context or TracingContext()
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
latency_tracker = UserBotLatencyObserver()
trace_observer = TurnTraceObserver(
turn_tracker,
latency_tracker=latency_tracker,
conversation_id=conversation_id,
tracing_context=tracing_context,
)
# Inject the test tracer so spans go to our in-memory exporter
trace_observer._tracer = self._tracer
return turn_tracker, latency_tracker, trace_observer, tracing_context
def _all_observers(self, trace_observer):
"""Return the list of observers needed for run_test."""
return [trace_observer._turn_tracker, trace_observer._latency_tracker, trace_observer]
def _get_spans_by_name(self, name):
"""Return finished spans with the given name."""
return [s for s in self._exporter.get_finished_spans() if s.name == name]
async def test_conversation_span_created_on_start_frame(self):
"""Test that a conversation span is created when StartFrame is observed."""
_, _, trace_observer, _ = self._create_observers(conversation_id="test-conv")
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span (normally done by PipelineTask._cleanup)
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(conv_spans[0].attributes["conversation.id"], "test-conv")
self.assertEqual(conv_spans[0].attributes["conversation.type"], "voice")
async def test_turn_spans_created_for_each_turn(self):
"""Test that a turn span is created for each conversation turn."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
# Turn 1
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.05),
# Turn 2
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(turn_spans), 2)
turn_numbers = {s.attributes["turn.number"] for s in turn_spans}
self.assertEqual(turn_numbers, {1, 2})
async def test_turn_spans_are_children_of_conversation(self):
"""Test that turn spans are parented under the conversation span."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(len(turn_spans), 1)
# Turn span's parent should be the conversation span
conv_span_id = conv_spans[0].context.span_id
turn_parent_id = turn_spans[0].parent.span_id
self.assertEqual(turn_parent_id, conv_span_id)
async def test_interrupted_turn_marked(self):
"""Test that an interrupted turn span has was_interrupted=True."""
_, _, trace_observer, _ = self._create_observers()
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
# User interrupts
UserStartedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
UserStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush remaining spans
trace_observer.end_conversation_tracing()
turn_spans = self._get_spans_by_name("turn")
self.assertGreaterEqual(len(turn_spans), 1)
# First turn should be interrupted
interrupted_turns = [s for s in turn_spans if s.attributes.get("turn.was_interrupted")]
self.assertGreaterEqual(len(interrupted_turns), 1)
async def test_tracing_context_updated_during_turn(self):
"""Test that TracingContext is populated during a turn and cleared after."""
tracing_ctx = TracingContext()
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# After the turn ends, turn context should be cleared
self.assertIsNone(tracing_ctx.get_turn_context())
async def test_tracing_context_cleared_after_conversation_end(self):
"""Test that TracingContext is cleared when conversation tracing ends."""
tracing_ctx = TracingContext()
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# Manually end conversation tracing (as PipelineTask._cleanup does)
trace_observer.end_conversation_tracing()
self.assertIsNone(tracing_ctx.get_conversation_context())
self.assertIsNone(tracing_ctx.get_turn_context())
self.assertIsNone(tracing_ctx.conversation_id)
async def test_additional_span_attributes(self):
"""Test that additional span attributes are added to the conversation span."""
extra_attrs = {"deployment.id": "abc-123", "customer.tier": "premium"}
tracing_ctx = TracingContext()
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
latency_tracker = UserBotLatencyObserver()
trace_observer = TurnTraceObserver(
turn_tracker,
latency_tracker=latency_tracker,
additional_span_attributes=extra_attrs,
tracing_context=tracing_ctx,
)
trace_observer._tracer = self._tracer
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[turn_tracker, latency_tracker, trace_observer],
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
self.assertEqual(conv_spans[0].attributes["deployment.id"], "abc-123")
self.assertEqual(conv_spans[0].attributes["customer.tier"], "premium")
async def test_concurrent_pipelines_are_isolated(self):
"""Test that two pipelines with separate TracingContexts don't interfere."""
tracing_ctx_a = TracingContext()
tracing_ctx_b = TracingContext()
_, _, trace_observer_a, _ = self._create_observers(
conversation_id="conv-a", tracing_context=tracing_ctx_a
)
_, _, trace_observer_b, _ = self._create_observers(
conversation_id="conv-b", tracing_context=tracing_ctx_b
)
processor_a = IdentityFilter()
processor_b = IdentityFilter()
frames = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
# Run both pipelines concurrently
await asyncio.gather(
run_test(
processor_a,
frames_to_send=frames,
expected_down_frames=expected,
observers=self._all_observers(trace_observer_a),
),
run_test(
processor_b,
frames_to_send=frames,
expected_down_frames=expected,
observers=self._all_observers(trace_observer_b),
),
)
# End both conversations to flush spans
trace_observer_a.end_conversation_tracing()
trace_observer_b.end_conversation_tracing()
# Each TracingContext should have its own conversation ID
conv_spans = self._get_spans_by_name("conversation")
conv_ids = {s.attributes["conversation.id"] for s in conv_spans}
self.assertEqual(conv_ids, {"conv-a", "conv-b"})
# Turn spans should be children of their own conversation span, not cross-linked
turn_spans = self._get_spans_by_name("turn")
conv_span_map = {s.context.span_id: s.attributes["conversation.id"] for s in conv_spans}
for turn_span in turn_spans:
parent_id = turn_span.parent.span_id
turn_conv_id = turn_span.attributes["conversation.id"]
parent_conv_id = conv_span_map[parent_id]
self.assertEqual(
turn_conv_id,
parent_conv_id,
f"Turn span for {turn_conv_id} parented under {parent_conv_id}",
)
async def test_end_conversation_closes_active_turn(self):
"""Test that end_conversation_tracing closes any active turn span."""
_, _, trace_observer, _ = self._create_observers()
# Manually start conversation and a turn
trace_observer.start_conversation_tracing("conv-end-test")
await trace_observer._handle_turn_started(1)
self.assertIsNotNone(trace_observer._current_span)
self.assertIsNotNone(trace_observer._conversation_span)
# End conversation — should close both turn and conversation
trace_observer.end_conversation_tracing()
self.assertIsNone(trace_observer._current_span)
self.assertIsNone(trace_observer._conversation_span)
# Check span attributes
turn_spans = self._get_spans_by_name("turn")
self.assertEqual(len(turn_spans), 1)
self.assertTrue(turn_spans[0].attributes["turn.was_interrupted"])
self.assertTrue(turn_spans[0].attributes["turn.ended_by_conversation_end"])
async def test_conversation_id_auto_generated(self):
"""Test that a conversation ID is auto-generated when none is provided."""
_, _, trace_observer, _ = self._create_observers(conversation_id=None)
processor = IdentityFilter()
frames_to_send = [
UserStartedSpeakingFrame(),
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
BotStoppedSpeakingFrame(),
SleepFrame(sleep=0.4),
]
expected_down_frames = [
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=self._all_observers(trace_observer),
)
# End conversation to flush the conversation span
trace_observer.end_conversation_tracing()
conv_spans = self._get_spans_by_name("conversation")
self.assertEqual(len(conv_spans), 1)
# Should have an auto-generated UUID as conversation.id
conv_id = conv_spans[0].attributes["conversation.id"]
self.assertIsNotNone(conv_id)
self.assertGreater(len(conv_id), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,631 @@
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
ClientConnectedFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
InterruptionFrame,
MetricsFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import (
TextAggregationMetricsData,
TTFBMetricsData,
)
from pipecat.observers.user_bot_latency_observer import (
FunctionCallMetrics,
LatencyBreakdown,
TextAggregationBreakdownMetrics,
TTFBBreakdownMetrics,
UserBotLatencyObserver,
)
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.tests.utils import SleepFrame, run_test
class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase):
"""Tests for UserBotLatencyObserver."""
async def test_normal_latency_measurement(self):
"""Test basic latency measurement from user stop to bot start."""
# Create observer
observer = UserBotLatencyObserver()
# Create identity filter (passes all frames through)
processor = IdentityFilter()
# Capture latency events
latencies = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
# Define frame sequence
frames_to_send = [
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
# Run test
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
# Verify latency was measured
self.assertEqual(len(latencies), 1)
self.assertGreater(latencies[0], 0)
self.assertLess(latencies[0], 1.0) # Should be very quick
async def test_multiple_latency_measurements(self):
"""Test that multiple user-bot exchanges produce separate latency events."""
# Create observer
observer = UserBotLatencyObserver()
# Create identity filter
processor = IdentityFilter()
# Capture latency events
latencies = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
# Define frame sequence with two complete cycles
frames_to_send = [
# First cycle
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
# Second cycle
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
# Run test
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
# Verify two separate latencies were measured
self.assertEqual(len(latencies), 2)
self.assertGreater(latencies[0], 0)
self.assertGreater(latencies[1], 0)
async def test_breakdown_with_metrics(self):
"""Test that metrics collected between VADUserStopped and BotStarted appear in breakdown."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
stt_ttfb = TTFBMetricsData(processor="DeepgramSTTService#0", value=0.080)
llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", model="gpt-4o", value=0.250)
tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070)
text_agg = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[stt_ttfb]),
MetricsFrame(data=[llm_ttfb, text_agg]),
MetricsFrame(data=[tts_ttfb]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
bd = breakdowns[0]
self.assertEqual(len(bd.ttfb), 3)
self.assertEqual(bd.ttfb[0].processor, "DeepgramSTTService#0")
self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0")
self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0")
self.assertIsNotNone(bd.text_aggregation)
self.assertEqual(bd.text_aggregation.duration_secs, 0.030)
async def test_interruption_resets_accumulators(self):
"""Test that InterruptionFrame clears stale metrics from earlier cycles."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
# First cycle metrics (will be interrupted)
stale_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.245)
# Second cycle metrics (the ones that matter)
final_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.224)
final_tts = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.142)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[stale_llm]),
InterruptionFrame(),
MetricsFrame(data=[final_llm]),
MetricsFrame(data=[final_tts]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
InterruptionFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
bd = breakdowns[0]
# Only the post-interruption metrics should be present
self.assertEqual(len(bd.ttfb), 2)
self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0")
self.assertEqual(bd.ttfb[0].duration_secs, 0.224)
self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0")
self.assertEqual(bd.ttfb[1].duration_secs, 0.142)
async def test_only_first_text_aggregation_kept(self):
"""Test that only the first text aggregation metric is kept per cycle."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
text_agg_1 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030)
text_agg_2 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.080)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
MetricsFrame(data=[text_agg_1]),
MetricsFrame(data=[text_agg_2]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNotNone(breakdowns[0].text_aggregation)
self.assertEqual(breakdowns[0].text_aggregation.duration_secs, 0.030)
async def test_user_turn_measured(self):
"""Test that pre-LLM wait from user silence to UserStopped is captured."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=0.1), # Simulate turn analyzer wait
UserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
UserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNotNone(breakdowns[0].user_turn_secs)
self.assertGreaterEqual(breakdowns[0].user_turn_secs, 0.1)
async def test_user_turn_none_without_user_stopped(self):
"""Test that user_turn is None when no UserStoppedSpeakingFrame arrives."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertIsNone(breakdowns[0].user_turn_secs)
async def test_no_measurement_without_user_stop(self):
"""Test that BotStartedSpeaking without prior user stop emits nothing."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
latencies = []
breakdowns = []
@observer.event_handler("on_latency_measured")
async def on_latency(obs, latency_seconds):
latencies.append(latency_seconds)
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
BotStartedSpeakingFrame(),
]
expected_down_frames = [
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(latencies), 0)
self.assertEqual(len(breakdowns), 0)
async def test_first_bot_speech_latency(self):
"""Test first bot speech latency and breakdown from ClientConnected to BotStartedSpeaking."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
breakdowns = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", value=0.250)
tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070)
frames_to_send = [
ClientConnectedFrame(),
MetricsFrame(data=[llm_ttfb]),
MetricsFrame(data=[tts_ttfb]),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
MetricsFrame,
MetricsFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 1)
self.assertGreater(first_speech_latencies[0], 0)
self.assertLess(first_speech_latencies[0], 1.0)
# Breakdown should also be emitted with the accumulated metrics
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].ttfb), 2)
self.assertEqual(breakdowns[0].ttfb[0].processor, "OpenAILLMService#0")
self.assertEqual(breakdowns[0].ttfb[1].processor, "CartesiaTTSService#0")
async def test_first_bot_speech_only_once(self):
"""Test that first bot speech latency is only emitted once."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
frames_to_send = [
ClientConnectedFrame(),
BotStartedSpeakingFrame(),
# Second bot speech should not trigger the event again
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
BotStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 1)
async def test_first_bot_speech_skipped_when_user_speaks_first(self):
"""Test that first bot speech event is not emitted when user speaks before the bot."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
first_speech_latencies = []
@observer.event_handler("on_first_bot_speech_latency")
async def on_first_bot_speech(obs, latency_seconds):
first_speech_latencies.append(latency_seconds)
frames_to_send = [
ClientConnectedFrame(),
# User speaks before bot has a chance to greet
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
BotStartedSpeakingFrame(),
]
expected_down_frames = [
ClientConnectedFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
BotStartedSpeakingFrame,
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=expected_down_frames,
observers=[observer],
)
self.assertEqual(len(first_speech_latencies), 0)
async def test_function_call_latency_in_breakdown(self):
"""Test that function call duration appears in the latency breakdown."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
tool_call_id = "call_abc123"
frames_to_send = [
VADUserStoppedSpeakingFrame(),
FunctionCallInProgressFrame(
function_name="get_weather",
tool_call_id=tool_call_id,
arguments={"location": "Atlanta"},
),
SleepFrame(sleep=0.1),
FunctionCallResultFrame(
function_name="get_weather",
tool_call_id=tool_call_id,
arguments={"location": "Atlanta"},
result={"temperature": "75"},
),
BotStartedSpeakingFrame(),
]
await run_test(
processor,
frames_to_send=frames_to_send,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].function_calls), 1)
fc = breakdowns[0].function_calls[0]
self.assertEqual(fc.function_name, "get_weather")
self.assertGreaterEqual(fc.duration_secs, 0.1)
async def test_function_call_reset_on_interruption(self):
"""Test that function call metrics are cleared on interruption."""
observer = UserBotLatencyObserver()
processor = IdentityFilter()
breakdowns = []
@observer.event_handler("on_latency_breakdown")
async def on_breakdown(obs, breakdown):
breakdowns.append(breakdown)
frames_to_send = [
VADUserStoppedSpeakingFrame(),
FunctionCallInProgressFrame(
function_name="get_weather",
tool_call_id="call_1",
arguments={},
),
FunctionCallResultFrame(
function_name="get_weather",
tool_call_id="call_1",
arguments={},
result={},
),
InterruptionFrame(),
BotStartedSpeakingFrame(),
]
await run_test(
processor,
frames_to_send=frames_to_send,
observers=[observer],
)
self.assertEqual(len(breakdowns), 1)
self.assertEqual(len(breakdowns[0].function_calls), 0)
class TestLatencyBreakdownChronologicalEvents(unittest.TestCase):
"""Tests for LatencyBreakdown.chronological_events()."""
def test_events_sorted_by_start_time(self):
"""Test that events are returned in chronological order."""
breakdown = LatencyBreakdown(
user_turn_start_time=100.0,
user_turn_secs=0.150,
ttfb=[
TTFBBreakdownMetrics(
processor="OpenAILLMService#0",
model="gpt-4o",
start_time=100.200,
duration_secs=0.250,
),
TTFBBreakdownMetrics(
processor="DeepgramSTTService#0",
start_time=100.050,
duration_secs=0.080,
),
TTFBBreakdownMetrics(
processor="CartesiaTTSService#0",
start_time=100.500,
duration_secs=0.070,
),
],
function_calls=[
FunctionCallMetrics(
function_name="get_weather",
start_time=100.450,
duration_secs=0.120,
),
],
text_aggregation=TextAggregationBreakdownMetrics(
processor="CartesiaTTSService#0",
start_time=100.480,
duration_secs=0.030,
),
)
events = breakdown.chronological_events()
self.assertEqual(len(events), 6)
self.assertEqual(events[0], "User turn: 0.150s")
self.assertEqual(events[1], "DeepgramSTTService#0: TTFB 0.080s")
self.assertEqual(events[2], "OpenAILLMService#0: TTFB 0.250s")
self.assertEqual(events[3], "get_weather: 0.120s")
self.assertEqual(events[4], "CartesiaTTSService#0: text aggregation 0.030s")
self.assertEqual(events[5], "CartesiaTTSService#0: TTFB 0.070s")
def test_empty_breakdown(self):
"""Test that an empty breakdown returns no events."""
breakdown = LatencyBreakdown()
self.assertEqual(breakdown.chronological_events(), [])
def test_user_turn_requires_both_fields(self):
"""Test that user turn is only included when both start_time and secs are set."""
# Only start_time, no duration
breakdown = LatencyBreakdown(user_turn_start_time=100.0)
self.assertEqual(breakdown.chronological_events(), [])
# Only duration, no start_time
breakdown = LatencyBreakdown(user_turn_secs=0.150)
self.assertEqual(breakdown.chronological_events(), [])
def test_ttfb_only(self):
"""Test breakdown with only TTFB metrics."""
breakdown = LatencyBreakdown(
ttfb=[
TTFBBreakdownMetrics(processor="LLM#0", start_time=100.0, duration_secs=0.200),
],
)
events = breakdown.chronological_events()
self.assertEqual(events, ["LLM#0: TTFB 0.200s"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,323 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
import unittest.mock
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
UserIdleTimeoutUpdateFrame,
UserStartedSpeakingFrame,
)
from pipecat.turns.user_idle_controller import UserIdleController
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
USER_IDLE_TIMEOUT = 0.2
class TestUserIdleController(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_idle_after_bot_stops_speaking(self):
"""Test that idle event fires after BotStoppedSpeakingFrame + timeout."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_user_speaking_cancels_timer(self):
"""Test that UserStartedSpeakingFrame cancels the idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(UserStartedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_bot_speaking_cancels_timer(self):
"""Test that BotStartedSpeakingFrame cancels the idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(BotStartedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_no_idle_before_bot_speaks(self):
"""Test that idle does not fire if no BotStoppedSpeakingFrame is received."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Wait without any frames
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_interruption_no_false_trigger(self):
"""Test that BotStoppedSpeakingFrame during a user turn does not start the timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# User starts speaking (interruption)
await controller.process_frame(UserStartedSpeakingFrame())
# Bot stops speaking due to interruption
await controller.process_frame(BotStoppedSpeakingFrame())
# Wait - timer should NOT have started because user turn is in progress
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_idle_cycle(self):
"""Test that idle fires, then can fire again after another bot speaking cycle."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_count = 0
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_count
idle_count += 1
# First cycle: bot stops → idle fires
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertEqual(idle_count, 1)
# Second cycle: bot starts → bot stops → idle fires again
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertEqual(idle_count, 2)
await controller.cleanup()
async def test_cleanup_cancels_timer(self):
"""Test that cleanup cancels a pending idle timer."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.cleanup()
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
async def test_function_call_cancels_timer(self):
"""Test normal ordering: BotStopped starts timer, FunctionCallsStarted cancels it."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Bot finishes speaking, timer starts
await controller.process_frame(BotStoppedSpeakingFrame())
# Function call starts shortly after, cancels the timer
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
await controller.process_frame(
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
)
# Wait longer than timeout — should not fire
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_function_call_suppresses_timer(self):
"""Test race condition: FunctionCallsStarted arrives before BotStopped.
A race condition can cause FunctionCallsStarted to arrive before
BotStoppedSpeaking. The counter guard prevents the timer from starting
while a function call is in progress.
"""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# LLM emits function call and "let me check" concurrently
await controller.process_frame(
FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()])
)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
# Wait longer than timeout — should not fire (function call in progress)
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
# Function call completes, bot speaks result
await controller.process_frame(
FunctionCallResultFrame(
function_name="test", tool_call_id="123", arguments={}, result="ok"
)
)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(BotStoppedSpeakingFrame())
# Now the timer should start and fire
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_disabled_by_default(self):
"""Test that timeout=0 means idle detection is disabled."""
controller = UserIdleController()
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
async def test_enable_via_frame(self):
"""Test enabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
controller = UserIdleController()
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Initially disabled — no idle fires
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
# Enable idle detection
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=USER_IDLE_TIMEOUT))
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertTrue(idle_triggered)
await controller.cleanup()
async def test_disable_via_frame(self):
"""Test disabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
await controller.setup(self.task_manager)
idle_triggered = False
@controller.event_handler("on_user_turn_idle")
async def on_user_turn_idle(controller):
nonlocal idle_triggered
idle_triggered = True
# Start the timer
await controller.process_frame(BotStoppedSpeakingFrame())
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
# Disable — should cancel running timer
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=0))
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
self.assertFalse(idle_triggered)
await controller.cleanup()
if __name__ == "__main__":
unittest.main()

View File

@@ -218,3 +218,7 @@ class TestUserIdleProcessor(unittest.IsolatedAsyncioTestCase):
)
assert callback_called.is_set(), "Idle callback not called after bot speech"
if __name__ == "__main__":
unittest.main()

View File

@@ -15,7 +15,7 @@ from pipecat.frames.frames import (
FunctionCallsStartedFrame,
InterruptionFrame,
)
from pipecat.turns.mute import (
from pipecat.turns.user_mute import (
AlwaysUserMuteStrategy,
FirstSpeechUserMuteStrategy,
FunctionCallUserMuteStrategy,
@@ -137,3 +137,7 @@ class TestFunctionCallUserMuteStrategy(unittest.IsolatedAsyncioTestCase):
)
)
self.assertFalse(await strategy.process_frame(InterruptionFrame()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,261 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
import unittest.mock
from unittest.mock import AsyncMock
from pipecat.frames.frames import LLMFullResponseEndFrame, LLMTextFrame
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import LLMService
from pipecat.services.settings import LLMSettings
from pipecat.turns.user_turn_completion_mixin import (
USER_TURN_COMPLETE_MARKER,
USER_TURN_COMPLETION_INSTRUCTIONS,
USER_TURN_INCOMPLETE_LONG_MARKER,
USER_TURN_INCOMPLETE_SHORT_MARKER,
UserTurnCompletionConfig,
UserTurnCompletionLLMServiceMixin,
)
class MockProcessor(UserTurnCompletionLLMServiceMixin, FrameProcessor):
"""Simple mock processor using the turn completion mixin."""
pass
class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase):
"""Tests for UserUserTurnCompletionLLMServiceMixin functionality."""
async def test_complete_marker_pushes_text(self):
"""Test that ✓ marker is detected and text after it is pushed normally."""
processor = MockProcessor()
# Capture frames that get pushed
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Simulate LLM generating: "✓ Hello there!"
await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello there!")
# Should have 2 text frames: marker (skip_tts) and content (normal)
self.assertEqual(len(pushed_frames), 2)
# First frame should be the marker with skip_tts=True
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_COMPLETE_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
# Second frame should be the actual text without skip_tts
self.assertIsInstance(pushed_frames[1], LLMTextFrame)
self.assertEqual(pushed_frames[1].text, "Hello there!")
self.assertFalse(pushed_frames[1].skip_tts)
async def test_incomplete_short_marker_suppresses_text(self):
"""Test that ○ marker suppresses text with skip_tts."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Mock timeout to avoid needing task manager
processor._start_incomplete_timeout = AsyncMock()
await processor._push_turn_text(USER_TURN_INCOMPLETE_SHORT_MARKER)
# Should have 1 text frame with skip_tts=True
self.assertEqual(len(pushed_frames), 1)
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_SHORT_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
async def test_incomplete_long_marker_suppresses_text(self):
"""Test that ◐ marker suppresses text with skip_tts."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Mock timeout to avoid needing task manager
processor._start_incomplete_timeout = AsyncMock()
await processor._push_turn_text(USER_TURN_INCOMPLETE_LONG_MARKER)
# Should have 1 text frame with skip_tts=True
self.assertEqual(len(pushed_frames), 1)
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_LONG_MARKER)
self.assertTrue(pushed_frames[0].skip_tts)
async def test_text_buffered_until_marker_found(self):
"""Test that text is buffered until a marker is detected."""
processor = MockProcessor()
pushed_frames = []
processor.push_frame = AsyncMock(
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
)
# Simulate token-by-token streaming without marker
await processor._push_turn_text("Hello")
await processor._push_turn_text(" there")
# No frames should be pushed yet (buffering)
self.assertEqual(len(pushed_frames), 0)
# Now send the complete marker
await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?")
# Now frames should be pushed
self.assertEqual(len(pushed_frames), 2)
async def test_turn_state_reset_after_llm_full_response_end_frame(self):
"""Test that _turn_complete_found is reset when LLMFullResponseEndFrame is pushed."""
processor = MockProcessor()
# Mock push_frame on the instance so _push_turn_text can call it without
# a live pipeline, but keep _turn_reset as the real implementation.
processor.push_frame = AsyncMock()
# Simulate first LLM response: complete marker sets _turn_complete_found = True
await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello!")
self.assertTrue(processor._turn_complete_found)
# Restore the real push_frame so the mixin override runs, then call it
# with LLMFullResponseEndFrame as the LLM service would.
del processor.push_frame # removes instance mock, restores class method
# Patch only the FrameProcessor-level send so no live pipeline is needed.
with unittest.mock.patch.object(FrameProcessor, "push_frame", AsyncMock()):
end_frame = LLMFullResponseEndFrame()
await processor.push_frame(end_frame)
# _turn_complete_found must now be False — ready for the next response
self.assertFalse(processor._turn_complete_found)
self.assertEqual(processor._turn_text_buffer, "")
self.assertFalse(processor._turn_suppressed)
class MockLLMService(LLMService):
"""Minimal LLM service for testing system_instruction composition."""
def __init__(self, **kwargs):
settings = LLMSettings(
model="test-model",
system_instruction=kwargs.pop("system_instruction", None),
temperature=None,
max_tokens=None,
top_p=None,
top_k=None,
frequency_penalty=None,
presence_penalty=None,
seed=None,
filter_incomplete_user_turns=None,
user_turn_completion_config=None,
)
super().__init__(settings=settings, **kwargs)
class TestSystemInstructionComposition(unittest.IsolatedAsyncioTestCase):
"""Tests for turn completion system_instruction composition in LLMService."""
async def test_enable_turn_completion_sets_system_instruction(self):
"""Enabling turn completion should set system_instruction to completion instructions."""
service = MockLLMService()
self.assertIsNone(service._settings.system_instruction)
delta = LLMSettings(filter_incomplete_user_turns=True)
await service._update_settings(delta)
self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS)
self.assertIsNone(service._base_system_instruction)
async def test_enable_turn_completion_appends_to_existing_system_instruction(self):
"""Enabling turn completion should append instructions to existing system_instruction."""
service = MockLLMService(system_instruction="You are a helpful assistant.")
delta = LLMSettings(filter_incomplete_user_turns=True)
await service._update_settings(delta)
expected = f"You are a helpful assistant.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
self.assertEqual(service._base_system_instruction, "You are a helpful assistant.")
async def test_disable_turn_completion_restores_system_instruction(self):
"""Disabling turn completion should restore the original system_instruction."""
service = MockLLMService(system_instruction="You are a helpful assistant.")
# Enable
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
self.assertIn(USER_TURN_COMPLETION_INSTRUCTIONS, service._settings.system_instruction)
# Disable
await service._update_settings(LLMSettings(filter_incomplete_user_turns=False))
self.assertEqual(service._settings.system_instruction, "You are a helpful assistant.")
self.assertIsNone(service._base_system_instruction)
async def test_disable_turn_completion_restores_none(self):
"""Disabling turn completion when original was None should restore None."""
service = MockLLMService()
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS)
await service._update_settings(LLMSettings(filter_incomplete_user_turns=False))
self.assertIsNone(service._settings.system_instruction)
async def test_update_system_instruction_while_turn_completion_active(self):
"""Changing system_instruction while turn completion is active should recompose."""
service = MockLLMService(system_instruction="Original prompt.")
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
expected = f"Original prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
# Now update system_instruction
await service._update_settings(LLMSettings(system_instruction="New prompt."))
expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
self.assertEqual(service._base_system_instruction, "New prompt.")
async def test_update_config_recomposes_with_custom_instructions(self):
"""Updating turn completion config should recompose with new instructions."""
service = MockLLMService(system_instruction="Base prompt.")
await service._update_settings(LLMSettings(filter_incomplete_user_turns=True))
custom_config = UserTurnCompletionConfig(instructions="Custom turn instructions.")
await service._update_settings(LLMSettings(user_turn_completion_config=custom_config))
expected = "Base prompt.\n\nCustom turn instructions."
self.assertEqual(service._settings.system_instruction, expected)
async def test_simultaneous_enable_and_system_instruction_change(self):
"""Enabling turn completion and changing system_instruction in the same delta
should use the new system_instruction as the base."""
service = MockLLMService(system_instruction="Original prompt.")
await service._update_settings(
LLMSettings(
filter_incomplete_user_turns=True,
system_instruction="New prompt.",
)
)
# apply_update sets system_instruction to "New prompt." before _update_settings
# runs, so the base should be the new value the user explicitly set.
self.assertEqual(service._base_system_instruction, "New prompt.")
expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}"
self.assertEqual(service._settings.system_instruction, expected)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2024-2026 Daily
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
@@ -8,15 +8,24 @@ import asyncio
import unittest
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_start import VADUserTurnStartStrategy
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
MinWordsUserTurnStartStrategy,
)
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_controller import UserTurnController
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
USER_TURN_STOP_TIMEOUT = 0.2
TRANSCRIPTION_TIMEOUT = 0.1
class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
@@ -25,7 +34,11 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def test_default_user_turn_strategies(self):
controller = UserTurnController(user_turn_strategies=UserTurnStrategies())
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
)
)
await controller.setup(self.task_manager)
@@ -54,8 +67,48 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
await controller.process_frame(VADUserStoppedSpeakingFrame())
self.assertTrue(should_start)
# Wait for user_speech_timeout to elapse
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertTrue(should_stop)
async def test_user_turn_start_reset(self):
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
start=[MinWordsUserTurnStartStrategy(min_words=3)]
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
should_start = 0
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start += 1
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp=""))
self.assertEqual(should_start, 0)
await controller.process_frame(
TranscriptionFrame(text="One two three!", user_id="cat", timestamp="")
)
self.assertEqual(should_start, 1)
# Trigger user stop turn so we can trigger user start turn again.
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
await controller.process_frame(BotStartedSpeakingFrame())
await controller.process_frame(TranscriptionFrame(text="Hi!", user_id="cat", timestamp=""))
self.assertEqual(should_start, 1)
await controller.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertEqual(should_start, 2)
async def test_user_turn_stop_timeout_no_transcription(self):
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(),
@@ -96,3 +149,124 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_external_user_turn_strategies_no_timeout_while_speaking(self):
"""Test that timeout does not trigger when user is still speaking with external strategies."""
controller = UserTurnController(
user_turn_strategies=ExternalUserTurnStrategies(),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
should_start = None
should_stop = None
timeout = None
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal should_start
should_start = True
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal should_stop
should_stop = True
@controller.event_handler("on_user_turn_stop_timeout")
async def on_user_turn_stop_timeout(controller):
nonlocal timeout
timeout = True
# Simulate external service (like Deepgram Flux) broadcasting UserStartedSpeakingFrame
await controller.process_frame(UserStartedSpeakingFrame())
self.assertTrue(should_start)
self.assertFalse(should_stop)
self.assertFalse(timeout)
# User is still speaking, timeout should not trigger
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertFalse(should_stop)
self.assertFalse(timeout)
# Now external service broadcasts UserStoppedSpeakingFrame
await controller.process_frame(UserStoppedSpeakingFrame())
# But no transcription, so timeout should trigger
await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertTrue(timeout)
async def test_late_transcription_between_turns_no_premature_stop(self):
"""Test that a late transcription arriving between turns does not cause a premature stop.
Reproduces the bug from issue #4053: after turn 1 completes and reset()
clears state, a late TranscriptionFrame sets _text to stale content. On
the next turn, that stale _text gates a premature turn stop via timeout(0)
before the current turn's transcript arrives.
Uses only VADUserTurnStartStrategy (no TranscriptionUserTurnStartStrategy)
so the late transcription doesn't trigger a spurious turn start.
"""
controller = UserTurnController(
user_turn_strategies=UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
await controller.setup(self.task_manager)
start_count = 0
stop_count = 0
@controller.event_handler("on_user_turn_started")
async def on_user_turn_started(controller, strategy, params):
nonlocal start_count
start_count += 1
@controller.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(controller, strategy, params):
nonlocal stop_count
stop_count += 1
# === Turn 1: S-T-E ===
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 1)
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 1)
# === Between turns: late transcription arrives ===
# This sets _text on the stop strategy while _user_turn is False.
await controller.process_frame(
TranscriptionFrame(text="Hello!", user_id="", timestamp="now")
)
# === Turn 2: S-T-E (transcription arrives during turn) ===
# The fix resets stop strategies at turn start, clearing stale _text.
await controller.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(start_count, 2)
await controller.process_frame(
TranscriptionFrame(text="How are you?", user_id="", timestamp="now")
)
await controller.process_frame(VADUserStoppedSpeakingFrame())
# Wait for user_speech_timeout to elapse — should get turn 2 stop
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -16,7 +16,7 @@ from pipecat.frames.frames import (
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.tests.utils import SleepFrame, run_test
from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
from pipecat.turns.user_turn_processor import UserTurnProcessor
from pipecat.turns.user_turn_strategies import UserTurnStrategies
@@ -26,7 +26,11 @@ TRANSCRIPTION_TIMEOUT = 0.1
class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
async def test_default_user_turn_strategies(self):
user_turn_processor = UserTurnProcessor(user_turn_strategies=UserTurnStrategies())
user_turn_processor = UserTurnProcessor(
user_turn_strategies=UserTurnStrategies(
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
)
)
should_start = None
should_stop = None
@@ -48,6 +52,8 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
]
expected_down_frames = [
VADUserStartedSpeakingFrame,
@@ -109,7 +115,7 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
async def test_user_turn_stop_timeout_transcription(self):
user_turn_processor = UserTurnProcessor(
user_turn_strategies=UserTurnStrategies(
stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)],
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
),
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
)
@@ -135,13 +141,13 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
pipeline = Pipeline([user_turn_processor])
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
frames_to_send = [
VADUserStartedSpeakingFrame(),
VADUserStoppedSpeakingFrame(),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT),
VADUserStoppedSpeakingFrame(),
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
]
await run_test(
pipeline,
@@ -152,3 +158,7 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
self.assertTrue(should_stop)
self.assertFalse(timeout)
if __name__ == "__main__":
unittest.main()

View File

@@ -38,7 +38,7 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
self.assertFalse(should_start)
await strategy.process_frame(
TranscriptionFrame(text=" there!", user_id="cat", timestamp="")
TranscriptionFrame(text="Hello there!", user_id="cat", timestamp="")
)
self.assertTrue(should_start)
@@ -55,6 +55,26 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
)
self.assertTrue(should_start)
async def test_bot_speaking_singlw_words(self):
strategy = MinWordsUserTurnStartStrategy(min_words=3)
should_start = None
@strategy.event_handler("on_user_turn_started")
async def on_user_turn_started(strategy, params):
nonlocal should_start
should_start = True
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(TranscriptionFrame(text="Two", user_id="cat", timestamp=""))
self.assertFalse(should_start)
await strategy.process_frame(TranscriptionFrame(text="Three", user_id="cat", timestamp=""))
self.assertFalse(should_start)
async def test_bot_speaking_interim_transcriptions(self):
strategy = MinWordsUserTurnStartStrategy(min_words=2)
@@ -179,3 +199,7 @@ class TestExternalUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(UserStartedSpeakingFrame())
self.assertTrue(should_start)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,25 +9,38 @@ import unittest
from pipecat.frames.frames import (
InterimTranscriptionFrame,
STTMetadataFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, TranscriptionUserTurnStopStrategy
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, SpeechTimeoutUserTurnStopStrategy
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
AGGREGATION_TIMEOUT = 0.1
# Use 0 STT timeout for deterministic test timing
STT_TIMEOUT = 0.0
class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
class TestSpeechTimeoutUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.task_manager = TaskManager()
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
async def _create_strategy(self, user_speech_timeout=AGGREGATION_TIMEOUT):
"""Create strategy and configure STT timeout via metadata frame."""
strategy = SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=user_speech_timeout)
await strategy.setup(self.task_manager)
# Set STT timeout via metadata frame (as would happen in real pipeline)
await strategy.process_frame(
STTMetadataFrame(service_name="test", ttfs_p99_latency=STT_TIMEOUT)
)
return strategy
async def test_ste(self):
strategy = TranscriptionUserTurnStopStrategy()
strategy = await self._create_strategy()
should_start = None
@@ -46,13 +59,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription comes in between user started/stopped and there are not
# interim, we just trigger bot speech.
# Transcription came in between user started/stopped. Now we wait for
# timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_site(self):
strategy = TranscriptionUserTurnStopStrategy()
strategy = await self._create_strategy()
should_start = None
@@ -77,13 +92,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription comes in between user started/stopped, so we trigger
# speech right away.
# Transcription came in between user started/stopped. Now we wait for
# timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_st1iest2e(self):
strategy = TranscriptionUserTurnStopStrategy()
strategy = await self._create_strategy()
should_start = None
@@ -122,15 +139,14 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# There was an interim before the first user stopped speaking, then we
# got a transcription comes in between user started/stopped, so we
# trigger speech right away.
# Now we wait for timeout before triggering.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_siet(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -163,8 +179,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_sieit(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -205,8 +220,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_set(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -235,8 +249,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_seit(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -271,8 +284,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_st1et2(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -291,26 +303,37 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription comes between user start/stopped speaking, we need to
# trigger speech right away.
# Transcription came between user start/stopped speaking, wait for timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
should_start = None
# Reset for next turn (in real usage, UserTurnController would do this)
await strategy.reset()
# S - new turn starts
await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertIsNone(should_start)
# T2
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
# E
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_set1t2(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -343,8 +366,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_siet1it2(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -388,8 +410,8 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
self.assertTrue(should_start)
async def test_t(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
"""Transcription without VAD - uses fallback timeout."""
strategy = await self._create_strategy()
should_start = None
@@ -402,14 +424,13 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
# Transcription without VAD triggers fallback timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_it(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
"""Interim + Transcription without VAD - uses fallback timeout."""
strategy = await self._create_strategy()
should_start = None
@@ -427,14 +448,12 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
self.assertIsNone(should_start)
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
# Transcription without VAD triggers fallback timeout.
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
async def test_sie_delay_it(self):
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
await strategy.setup(self.task_manager)
strategy = await self._create_strategy()
should_start = None
@@ -456,24 +475,67 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(VADUserStoppedSpeakingFrame())
self.assertIsNone(should_start)
# Delay
# Delay - timeout expires but no transcript yet
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
# Still no trigger because no transcript received
self.assertIsNone(should_start)
# I
await strategy.process_frame(
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
)
# T
# T (finalized) - triggers immediately since timeout already elapsed
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="", finalized=True)
)
# Finalized transcript received after timeout, triggers immediately
self.assertTrue(should_start)
async def test_reset_clears_stale_text_no_premature_stop(self):
"""Test that reset() clears stale text and cancels timeout, preventing premature stop.
Reproduces the bug from issue #4053: after turn 1 completes and
reset() is called, a late transcription sets _text. If reset() is
called again at turn 2 start, the stale _text should be cleared
so no premature stop occurs on VAD stop.
"""
strategy = await self._create_strategy()
stop_count = 0
@strategy.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(strategy, params):
nonlocal stop_count
stop_count += 1
# === Turn 1: S-T-E ===
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
await strategy.process_frame(VADUserStoppedSpeakingFrame())
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 1)
# Reset after turn 1 (as controller would do at turn stop)
await strategy.reset()
# === Late transcription arrives between turns ===
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
# Reset at turn 2 start (the fix: controller now resets stop strategies at turn start)
await strategy.reset()
# === Turn 2: S-T-E (transcription arrives during turn) ===
await strategy.process_frame(VADUserStartedSpeakingFrame())
await strategy.process_frame(
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
)
self.assertIsNone(should_start)
await strategy.process_frame(VADUserStoppedSpeakingFrame())
# Transcription comes after user stopped speaking, we need to wait for
# at least the aggregation timeout.
# Wait for timeout — should get turn 2 stop with the real transcription
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertTrue(should_start)
self.assertEqual(stop_count, 2)
class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
@@ -506,3 +568,7 @@ class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(UserStoppedSpeakingFrame())
self.assertTrue(should_start)
if __name__ == "__main__":
unittest.main()

View File

@@ -32,3 +32,7 @@ class TestUtilsNetwork(unittest.IsolatedAsyncioTestCase):
assert exponential_backoff_time(attempt=4, min_wait=1, max_wait=20, multiplier=2) == 16
assert exponential_backoff_time(attempt=5, min_wait=1, max_wait=20, multiplier=2) == 20
assert exponential_backoff_time(attempt=6, min_wait=1, max_wait=20, multiplier=2) == 20
if __name__ == "__main__":
unittest.main()

View File

@@ -153,6 +153,46 @@ class TestUtilsString(unittest.IsolatedAsyncioTestCase):
for sentence in latin_script_sentences:
assert match_endofsentence(sentence), f"Failed for Latin script: {sentence}"
async def test_endofsentence_cjk_with_lookahead(self):
"""Test sentence detection for CJK text with lookahead characters.
This tests the NLTK fallback path: NLTK returns entire text as one
sentence because it doesn't support CJK languages, but unambiguous
punctuation is detected via the fallback scan.
"""
# Japanese: sentence + lookahead character
assert match_endofsentence("こんにちは。元") == 6
assert match_endofsentence("元気ですか?は") == 6
assert match_endofsentence("ありがとう!次") == 6
# Chinese: sentence + lookahead character
assert match_endofsentence("你好世界。下") == 5
assert match_endofsentence("你好吗?我") == 4
# Korean: sentence + lookahead character
assert match_endofsentence("안녕하세요。다") == 6
# Multiple CJK sentences with lookahead - should return first sentence
assert match_endofsentence("こんにちは。元気ですか?は") == 6
# Indic script with lookahead
assert match_endofsentence("हैलो।अ") == 5
# Arabic with lookahead
assert match_endofsentence("مرحبا؟ك") == 6
async def test_endofsentence_latin_not_affected_by_fallback(self):
"""Verify that the CJK fallback does not change behavior for Latin text."""
# These should still return 0 - Latin "." is NOT in the unambiguous set
assert not match_endofsentence("Mr. S")
assert not match_endofsentence("Ok, Mr. Smith let's ")
assert not match_endofsentence("The number pi is 3.14159")
assert not match_endofsentence("America, or the U.S")
# These should still return correct values via NLTK path
assert match_endofsentence("This is a sentence. This is another one") == 19
assert match_endofsentence("For information, call 411.") == 26
async def test_endofsentence_streaming_tokens(self):
"""Test the specific use case of streaming LLM tokens."""
@@ -232,3 +272,7 @@ class TestStartEndTags(unittest.IsolatedAsyncioTestCase):
("<a>", "</a>"),
41,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,210 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from typing import List
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams, VADState
from pipecat.audio.vad.vad_controller import VADController
from pipecat.frames.frames import Frame, InputAudioRawFrame, SpeechControlParamsFrame, StartFrame
from pipecat.processors.frame_processor import FrameDirection
class MockVADAnalyzer(VADAnalyzer):
"""A mock VAD analyzer that returns a configurable state."""
def __init__(self):
"""Initialize with default QUIET state."""
super().__init__(sample_rate=16000)
self._next_state = VADState.QUIET
def set_next_state(self, state: VADState):
"""Set the state to return on the next analyze_audio call.
Args:
state: The VADState to return.
"""
self._next_state = state
def num_frames_required(self) -> int:
return 512
def voice_confidence(self, buffer: bytes) -> float:
return 0.9
async def analyze_audio(self, buffer: bytes) -> VADState:
"""Return the configured state."""
return self._next_state
class TestVADController(unittest.IsolatedAsyncioTestCase):
async def test_speech_started_event(self):
"""Test that on_speech_started event is triggered when speech begins."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
speech_started = False
@controller.event_handler("on_speech_started")
async def on_speech_started(_controller):
nonlocal speech_started
speech_started = True
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Process with QUIET state - no event should fire
analyzer.set_next_state(VADState.QUIET)
await controller.process_frame(audio_frame)
self.assertFalse(speech_started)
# Process with SPEAKING state - event should fire
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
self.assertTrue(speech_started)
async def test_speech_stopped_event(self):
"""Test that on_speech_stopped event is triggered when speech ends."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
speech_stopped = False
@controller.event_handler("on_speech_stopped")
async def on_speech_stopped(_controller):
nonlocal speech_stopped
speech_stopped = True
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Start speaking
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
self.assertFalse(speech_stopped)
# Stop speaking - event should fire
analyzer.set_next_state(VADState.QUIET)
await controller.process_frame(audio_frame)
self.assertTrue(speech_stopped)
async def test_speech_activity_event(self):
"""Test that on_speech_activity event is triggered while speaking."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
activity_count = 0
@controller.event_handler("on_speech_activity")
async def on_speech_activity(_controller):
nonlocal activity_count
activity_count += 1
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# Activity events fire while in SPEAKING state
analyzer.set_next_state(VADState.SPEAKING)
await controller.process_frame(audio_frame)
await controller.process_frame(audio_frame)
self.assertEqual(activity_count, 2)
async def test_push_frame_event(self):
"""Test that push_frame emits on_push_frame event."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
pushed_frames: List[tuple] = []
@controller.event_handler("on_push_frame")
async def on_push_frame(_controller, frame: Frame, direction: FrameDirection):
pushed_frames.append((frame, direction))
test_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
await controller.push_frame(test_frame, FrameDirection.DOWNSTREAM)
self.assertEqual(len(pushed_frames), 1)
self.assertEqual(pushed_frames[0][0], test_frame)
self.assertEqual(pushed_frames[0][1], FrameDirection.DOWNSTREAM)
async def test_broadcast_frame_event(self):
"""Test that broadcast_frame emits on_broadcast_frame event."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
broadcast_calls: List[tuple] = []
@controller.event_handler("on_broadcast_frame")
async def on_broadcast_frame(_controller, frame_cls, **kwargs):
broadcast_calls.append((frame_cls, kwargs))
await controller.broadcast_frame(
InputAudioRawFrame, audio=b"\x00", sample_rate=16000, num_channels=1
)
self.assertEqual(len(broadcast_calls), 1)
self.assertEqual(broadcast_calls[0][0], InputAudioRawFrame)
self.assertEqual(broadcast_calls[0][1]["sample_rate"], 16000)
async def test_no_event_on_transitional_states(self):
"""Test that STARTING and STOPPING states don't trigger events."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
events_triggered = []
@controller.event_handler("on_speech_started")
async def on_speech_started(_controller):
events_triggered.append("started")
@controller.event_handler("on_speech_stopped")
async def on_speech_stopped(_controller):
events_triggered.append("stopped")
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
# STARTING is a transitional state - no event
analyzer.set_next_state(VADState.STARTING)
await controller.process_frame(audio_frame)
self.assertEqual(events_triggered, [])
# STOPPING is a transitional state - no event
analyzer.set_next_state(VADState.STOPPING)
await controller.process_frame(audio_frame)
self.assertEqual(events_triggered, [])
async def test_start_frame_broadcasts_vad_params(self):
"""Test that StartFrame triggers broadcast of SpeechControlParamsFrame with VAD params."""
analyzer = MockVADAnalyzer()
controller = VADController(analyzer)
broadcast_calls: List[tuple] = []
@controller.event_handler("on_broadcast_frame")
async def on_broadcast_frame(_controller, frame_cls, **kwargs):
broadcast_calls.append((frame_cls, kwargs))
start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000)
await controller.process_frame(start_frame)
# Should have broadcast SpeechControlParamsFrame with VAD params
self.assertEqual(len(broadcast_calls), 1)
self.assertEqual(broadcast_calls[0][0], SpeechControlParamsFrame)
self.assertIn("vad_params", broadcast_calls[0][1])
self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams)
if __name__ == "__main__":
unittest.main()

150
tests/test_vad_processor.py Normal file
View File

@@ -0,0 +1,150 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from typing import List
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState
from pipecat.frames.frames import (
InputAudioRawFrame,
SpeechControlParamsFrame,
UserSpeakingFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.processors.audio.vad_processor import VADProcessor
from pipecat.tests.utils import run_test
class MockVADAnalyzer(VADAnalyzer):
"""A mock VAD analyzer that returns states from a predefined sequence."""
def __init__(self, states: List[VADState]):
super().__init__(sample_rate=16000)
self._states = list(states)
self._call_index = 0
def num_frames_required(self) -> int:
return 512
def voice_confidence(self, buffer: bytes) -> float:
return 0.9
async def analyze_audio(self, buffer: bytes) -> VADState:
if self._call_index < len(self._states):
state = self._states[self._call_index]
self._call_index += 1
return state
return VADState.QUIET
class TestVADProcessor(unittest.IsolatedAsyncioTestCase):
def _make_audio_frame(self):
return InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1)
async def test_forwards_audio_frames(self):
"""Test that audio frames are forwarded downstream."""
analyzer = MockVADAnalyzer([VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_pushes_started_speaking_frame(self):
"""Test that VADUserStartedSpeakingFrame is pushed when speech starts."""
analyzer = MockVADAnalyzer([VADState.QUIET, VADState.SPEAKING])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
],
)
async def test_pushes_stopped_speaking_frame(self):
"""Test that VADUserStoppedSpeakingFrame is pushed when speech stops."""
analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
InputAudioRawFrame,
VADUserStoppedSpeakingFrame,
],
)
async def test_pushes_user_speaking_frame(self):
"""Test that UserSpeakingFrame is pushed while speaking."""
analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.SPEAKING])
processor = VADProcessor(vad_analyzer=analyzer)
# Audio frames are forwarded first, then VAD processes and broadcasts VAD frames
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[
SpeechControlParamsFrame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
UserSpeakingFrame,
InputAudioRawFrame,
UserSpeakingFrame,
],
)
async def test_no_vad_frames_on_starting_state(self):
"""Test that STARTING state doesn't push VAD frames."""
analyzer = MockVADAnalyzer([VADState.STARTING])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_no_vad_frames_on_stopping_state(self):
"""Test that STOPPING state doesn't push VAD frames."""
analyzer = MockVADAnalyzer([VADState.STOPPING])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame],
)
async def test_no_vad_frames_when_quiet(self):
"""Test that no VAD frames are pushed when staying quiet."""
analyzer = MockVADAnalyzer([VADState.QUIET, VADState.QUIET])
processor = VADProcessor(vad_analyzer=analyzer)
await run_test(
processor,
frames_to_send=[self._make_audio_frame(), self._make_audio_frame()],
expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame, InputAudioRawFrame],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,346 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
BotSpeakingFrame,
InterimTranscriptionFrame,
TranscriptionFrame,
UserSpeakingFrame,
VADUserStartedSpeakingFrame,
)
from pipecat.turns.types import ProcessFrameResult
from pipecat.turns.user_start.wake_phrase_user_turn_start_strategy import (
WakePhraseUserTurnStartStrategy,
_WakeState,
)
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
class TestWakePhraseUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase):
def _create_strategy(self, **kwargs) -> WakePhraseUserTurnStartStrategy:
kwargs.setdefault("phrases", ["hey pipecat"])
kwargs.setdefault("timeout", 10.0)
return WakePhraseUserTurnStartStrategy(**kwargs)
async def _setup_strategy(self, strategy: WakePhraseUserTurnStartStrategy):
task_manager = TaskManager()
loop = asyncio.get_running_loop()
task_manager.setup(TaskManagerParams(loop=loop))
await strategy.setup(task_manager)
return task_manager
async def test_wake_phrase_in_final_transcription(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_interim_transcription_ignored(self):
"""Interim transcriptions are never used for wake phrase matching."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
InterimTranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_no_wake_phrase_returns_stop(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hello world", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_non_matching_text_resets_aggregation(self):
"""Non-matching transcription triggers aggregation reset to prevent LLM context pollution."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
reset_called = False
@strategy.event_handler("on_reset_aggregation")
async def on_reset_aggregation(strategy):
nonlocal reset_called
reset_called = True
await strategy.process_frame(
TranscriptionFrame(text="hello world", user_id="user1", timestamp="")
)
self.assertTrue(reset_called)
await strategy.cleanup()
async def test_vad_frame_returns_stop_in_listening(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_inactive_returns_continue(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
# Trigger wake phrase first.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Subsequent frames should return CONTINUE.
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.CONTINUE)
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.CONTINUE)
await strategy.cleanup()
async def test_accumulation_across_frames(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="hey", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.IDLE)
result = await strategy.process_frame(
TranscriptionFrame(text="pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_multiple_phrases(self):
strategy = self._create_strategy(phrases=["hey pipecat", "ok computer"])
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="ok computer", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_punctuation_stripped(self):
"""STT punctuation like 'Hey, Pipecat!' should still match."""
strategy = self._create_strategy()
await self._setup_strategy(strategy)
result = await strategy.process_frame(
TranscriptionFrame(text="Hey, Pipecat!", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_reset_preserves_inactive_state(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.reset()
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
async def test_timeout_returns_to_listening(self):
strategy = self._create_strategy(timeout=0.1)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for timeout to expire.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_activity_refreshes_timeout(self):
strategy = self._create_strategy(timeout=0.2)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Send activity before timeout.
await asyncio.sleep(0.1)
await strategy.process_frame(UserSpeakingFrame())
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Send more activity.
await asyncio.sleep(0.1)
await strategy.process_frame(BotSpeakingFrame())
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for timeout to expire after last activity.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
await strategy.cleanup()
async def test_wake_phrase_detected_event(self):
strategy = self._create_strategy()
await self._setup_strategy(strategy)
detected_phrase = None
@strategy.event_handler("on_wake_phrase_detected")
async def on_wake_phrase_detected(strategy, phrase):
nonlocal detected_phrase
detected_phrase = phrase
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
# Event fires in a background task, give it a moment.
await asyncio.sleep(0.05)
self.assertEqual(detected_phrase, "hey pipecat")
await strategy.cleanup()
async def test_wake_phrase_timeout_event(self):
strategy = self._create_strategy(timeout=0.1)
await self._setup_strategy(strategy)
timeout_fired = False
@strategy.event_handler("on_wake_phrase_timeout")
async def on_wake_phrase_timeout(strategy):
nonlocal timeout_fired
timeout_fired = True
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
# Wait for timeout.
await asyncio.sleep(0.3)
self.assertTrue(timeout_fired)
await strategy.cleanup()
async def test_single_activation_stays_inactive_after_reset(self):
"""In single activation mode, reset() keeps INACTIVE so the current turn can finish."""
strategy = self._create_strategy(single_activation=True, timeout=0.5)
await self._setup_strategy(strategy)
# Trigger wake phrase.
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Simulate turn start (controller calls reset on all start strategies).
await strategy.reset()
# State remains INACTIVE so frames continue to flow.
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Subsequent frames should pass through (CONTINUE).
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.CONTINUE)
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.CONTINUE)
await strategy.cleanup()
async def test_single_activation_timeout_returns_to_listening(self):
"""In single activation mode, the keepalive timeout returns to LISTENING."""
strategy = self._create_strategy(single_activation=True, timeout=0.1)
await self._setup_strategy(strategy)
# Trigger wake phrase.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
# Wait for keepalive timeout to expire.
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
# Frames should now be blocked again.
result = await strategy.process_frame(VADUserStartedSpeakingFrame())
self.assertEqual(result, ProcessFrameResult.STOP)
await strategy.cleanup()
async def test_single_activation_requires_wake_phrase_after_timeout(self):
"""Single activation mode requires wake phrase again after keepalive timeout."""
strategy = self._create_strategy(single_activation=True, timeout=0.1)
await self._setup_strategy(strategy)
# First turn: wake phrase -> INACTIVE -> timeout -> LISTENING.
await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await asyncio.sleep(0.3)
self.assertEqual(strategy.state, _WakeState.IDLE)
# Without wake phrase, frames are blocked.
result = await strategy.process_frame(
TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
# Second turn: wake phrase again.
result = await strategy.process_frame(
TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="")
)
self.assertEqual(result, ProcessFrameResult.STOP)
self.assertEqual(strategy.state, _WakeState.AWAKE)
await strategy.cleanup()
if __name__ == "__main__":
unittest.main()