The AudioHook protocol requires every message to carry a `parameters` object. `_create_message` conditionally included it only when parameters were truthy, so pong responses and closed responses without outputVariables were sent without the field. Clients that validate message structure (including the Genesys reference implementation) rejected these messages, which broke server sequence tracking and prevented outputVariables from reaching the Architect flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
#
|
|
# 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
|