chore: refactor status processor (#465)

* chore: refactor status processor

* fix: fix billing duration when billsec is None for Cloudonix
This commit is contained in:
Abhishek 2026-06-24 22:07:35 +05:30 committed by GitHub
parent d817d50056
commit 29c5be298c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 910 additions and 809 deletions

View file

@ -160,14 +160,6 @@ def patch_run_pipeline_externals(
NoopFeedbackObserver,
)
)
# Disposition mapper would otherwise call out to the LLM.
stack.enter_context(
patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
)
)
# Capture the PipelineWorker so the test can drive it from outside.
stack.enter_context(
patch(

View file

@ -11,8 +11,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from starlette.requests import Request
from api.enums import TelephonyCallStatus
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
from api.services.telephony.providers.cloudonix.routes import handle_cloudonix_cdr
from api.services.telephony.status_processor import StatusCallbackRequest
def _json_request(body: bytes) -> Request:
@ -79,33 +80,33 @@ async def test_cdr_route_handles_string_session():
assert result == {"status": "error", "message": "Missing call_id field"}
def test_from_cloudonix_cdr_tolerates_missing_session_and_disposition():
"""``from_cloudonix_cdr`` must not crash on a partial CDR payload."""
def test_parse_cloudonix_cdr_tolerates_missing_session_and_disposition():
"""Cloudonix CDR parsing must not crash on a partial payload."""
# Missing both session and disposition.
req = StatusCallbackRequest.from_cloudonix_cdr({"domain": "acme.cloudonix.io"})
assert req.call_id == ""
assert req.status == ""
req = CloudonixProvider.parse_cdr_status_callback({"domain": "acme.cloudonix.io"})
assert req["call_id"] == ""
assert req["status"] == ""
# Explicit null values.
req = StatusCallbackRequest.from_cloudonix_cdr(
req = CloudonixProvider.parse_cdr_status_callback(
{"session": None, "disposition": None}
)
assert req.call_id == ""
assert req.status == ""
assert req["call_id"] == ""
assert req["status"] == ""
def test_from_cloudonix_cdr_tolerates_string_session():
"""``from_cloudonix_cdr`` treats a non-object session as missing call_id."""
req = StatusCallbackRequest.from_cloudonix_cdr(
def test_parse_cloudonix_cdr_tolerates_string_session():
"""Cloudonix CDR parsing treats a non-object session as missing call_id."""
req = CloudonixProvider.parse_cdr_status_callback(
{"session": "abc", "disposition": "ANSWER"}
)
assert req.call_id == ""
assert req.status == "completed"
assert req["call_id"] == ""
assert req["status"] == TelephonyCallStatus.COMPLETED
def test_from_cloudonix_cdr_maps_disposition_and_session_token():
def test_parse_cloudonix_cdr_maps_disposition_and_session_token():
"""Normal, well-formed CDR payloads still map correctly."""
req = StatusCallbackRequest.from_cloudonix_cdr(
req = CloudonixProvider.parse_cdr_status_callback(
{
"session": {"token": "abc123"},
"disposition": "BUSY",
@ -114,6 +115,20 @@ def test_from_cloudonix_cdr_maps_disposition_and_session_token():
"billsec": 12,
}
)
assert req.call_id == "abc123"
assert req.status == "busy"
assert req.duration == "12"
assert req["call_id"] == "abc123"
assert req["status"] == TelephonyCallStatus.BUSY
assert req["duration"] == "12"
def test_parse_cloudonix_cdr_preserves_zero_billsec():
"""A zero billed duration must not fall back to total call duration."""
req = CloudonixProvider.parse_cdr_status_callback(
{
"session": {"token": "abc123"},
"disposition": "ANSWER",
"billsec": 0,
"duration": 42,
}
)
assert req["duration"] == "0"

View file

@ -0,0 +1,98 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from api.enums import TelephonyCallStatus, WorkflowRunState
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.tasks.function_names import FunctionNames
@pytest.mark.asyncio
async def test_initialized_no_answer_enqueues_workflow_completion():
workflow_run = SimpleNamespace(
id=123,
campaign_id=None,
queued_run_id=None,
state=WorkflowRunState.INITIALIZED.value,
is_completed=False,
logs={"telephony_status_callbacks": []},
gathered_context={"call_tags": ["existing"]},
)
status = StatusCallbackRequest(
call_id="call-123",
status="No-Answer",
)
with (
patch("api.services.telephony.status_processor.db_client") as mock_db,
patch(
"api.services.telephony.status_processor.enqueue_job",
new_callable=AsyncMock,
) as mock_enqueue,
):
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
mock_db.update_workflow_run = AsyncMock()
await _process_status_update(123, status)
log_update = mock_db.update_workflow_run.await_args_list[0].kwargs
callback_log = log_update["logs"]["telephony_status_callbacks"][0]
assert callback_log["status"] == "no-answer"
assert callback_log["call_id"] == "call-123"
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
assert completion_update["run_id"] == 123
assert completion_update["is_completed"] is True
assert completion_update["state"] == WorkflowRunState.COMPLETED.value
assert completion_update["usage_info"] == {"call_duration_seconds": 0}
assert completion_update["gathered_context"] == {
"call_tags": ["existing", "not_connected", "telephony_no-answer"],
"call_disposition": "no-answer",
"mapped_call_disposition": "no-answer",
"call_id": "call-123",
}
mock_enqueue.assert_awaited_once_with(
FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, 123
)
@pytest.mark.asyncio
async def test_running_terminal_status_does_not_enqueue_workflow_completion():
workflow_run = SimpleNamespace(
id=456,
campaign_id=None,
queued_run_id=None,
state=WorkflowRunState.RUNNING.value,
is_completed=False,
logs={"telephony_status_callbacks": []},
gathered_context={"call_tags": ["not_connected"]},
)
status = StatusCallbackRequest(
call_id="call-456",
status=TelephonyCallStatus.FAILED,
duration="7",
)
with (
patch("api.services.telephony.status_processor.db_client") as mock_db,
patch(
"api.services.telephony.status_processor.enqueue_job",
new_callable=AsyncMock,
) as mock_enqueue,
):
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
mock_db.update_workflow_run = AsyncMock()
await _process_status_update(456, status)
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
assert "usage_info" not in completion_update
assert completion_update["gathered_context"]["call_tags"] == [
"not_connected",
"telephony_failed",
]
mock_enqueue.assert_not_awaited()

View file

@ -126,22 +126,17 @@ async def run_pipeline_and_capture_context(
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
return llm, context

View file

@ -268,28 +268,23 @@ class TestEndCallViaNodeTransition:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called
assert len(test_helper.end_call_reasons) >= 1, (
@ -371,28 +366,23 @@ class TestEndCallViaNodeTransition:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"greeting_type": "formal", "user_name": "John"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"greeting_type": "formal", "user_name": "John"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Should have 3 LLM generations
assert llm.get_current_step() == 3
@ -469,28 +459,23 @@ class TestEndCallViaCustomTool:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_call_tool",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called with END_CALL_TOOL_REASON
assert len(test_helper.end_call_reasons) >= 1, (
@ -560,28 +545,23 @@ class TestEndCallViaCustomTool:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_call_tool",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called
assert len(test_helper.end_call_reasons) >= 1, (
@ -637,37 +617,32 @@ class TestEndCallViaClientDisconnect:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="user_hangup",
return_value={"user_intent": "disconnected"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "disconnected"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_disconnect():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_disconnect():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation to complete
await asyncio.sleep(0.1)
# Wait for initial generation to complete
await asyncio.sleep(0.1)
# Simulate client disconnect by calling end_call_with_reason directly
# This is what on_client_disconnected does
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# Simulate client disconnect by calling end_call_with_reason directly
# This is what on_client_disconnected does
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
# Verify end_call_with_reason was called with USER_HANGUP
assert EndTaskReason.USER_HANGUP.value in test_helper.end_call_reasons, (
@ -727,46 +702,41 @@ class TestEndCallRaceConditions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="first_reason",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_race():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_race():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# Try to end call multiple times concurrently
await asyncio.gather(
engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
),
engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,
),
engine.end_call_with_reason(
EndTaskReason.USER_QUALIFIED.value,
abort_immediately=False,
),
)
# Try to end call multiple times concurrently
await asyncio.gather(
engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
),
engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,
),
engine.end_call_with_reason(
EndTaskReason.USER_QUALIFIED.value,
abort_immediately=False,
),
)
await asyncio.gather(run_pipeline(), initialize_and_race())
await asyncio.gather(run_pipeline(), initialize_and_race())
# Due to the _call_disposed guard, only one end_call should fully execute
# The tracked end_call_reasons will show all attempted calls
@ -838,41 +808,34 @@ class TestEndCallRaceConditions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_reason",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_race_disconnect():
nonlocal disconnect_called
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_race_disconnect():
nonlocal disconnect_called
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for the end_call tool to be called
await asyncio.sleep(0.15)
# Wait for the end_call tool to be called
await asyncio.sleep(0.15)
# Simulate client disconnect racing with end_call tool
disconnect_called = True
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(
run_pipeline(), initialize_and_race_disconnect()
# Simulate client disconnect racing with end_call tool
disconnect_called = True
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_race_disconnect())
# Verify disconnect was attempted
assert disconnect_called, "Disconnect should have been called"
@ -933,40 +896,35 @@ class TestEndCallExtractionBehavior:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
side_effect=mock_extraction,
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
side_effect=mock_extraction,
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# Verify extraction was awaited (synchronous)
assert extraction_completed.is_set(), (
"Extraction should have completed before end_call returned"
)
# Verify extraction was awaited (synchronous)
assert extraction_completed.is_set(), (
"Extraction should have completed before end_call returned"
)
await asyncio.gather(run_pipeline(), initialize_and_end())
await asyncio.gather(run_pipeline(), initialize_and_end())
# Verify synchronous extraction was used
sync_extractions = [
@ -1058,35 +1016,30 @@ class TestEndCallExtractionBehavior:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
extraction_mock,
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
extraction_mock,
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_end())
await asyncio.gather(run_pipeline(), initialize_and_end())
# Extraction should have been called but the inner _perform_extraction
# should not have been called because extraction_enabled=False

View file

@ -281,24 +281,19 @@ class TestNodeSwitchWithUserSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start the LLM generation - user speech will be injected
# automatically when FunctionCallResultFrame #1 is seen
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start the LLM generation - user speech will be injected
# automatically when FunctionCallResultFrame #1 is seen
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Total 4 generations out of which 1 was cancelled due to interruption
assert llm.get_current_step() == 4

View file

@ -117,24 +117,19 @@ async def run_pipeline_with_tool_calls(
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Run both concurrently
await asyncio.gather(run_pipeline(), initialize_engine())
# Run both concurrently
await asyncio.gather(run_pipeline(), initialize_engine())
return llm, context

View file

@ -171,31 +171,26 @@ class TestTransitionFunctionMutesUser:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
assert len(captured_states) == 1, (
f"Expected the transition function to be invoked exactly once, "
@ -245,31 +240,26 @@ class TestTransitionFunctionMutesUser:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
assert function_call_mute_strategy._function_call_in_progress == set(), (
"FunctionCallUserMuteStrategy should have cleared its in-progress "

View file

@ -156,29 +156,24 @@ class TestVariableExtractionDuringTransitions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
# Mock the actual extraction to avoid needing a real LLM
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_name": "John Doe"},
):
# Mock the actual extraction to avoid needing a real LLM
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_name": "John Doe"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Should have 3 LLM generations
assert llm.get_current_step() == 3

View file

@ -0,0 +1,88 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.services.workflow.dto import WebhookNodeData
from api.tasks.run_integrations import _execute_webhook_node
def _mock_httpx_client(captured: dict):
"""Build a patch target for httpx.AsyncClient that records the request kwargs."""
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
async def _request(**kwargs):
captured.update(kwargs)
return response
client = MagicMock()
client.request = AsyncMock(side_effect=_request)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=client)
ctx.__aexit__ = AsyncMock(return_value=False)
return MagicMock(return_value=ctx)
@pytest.mark.asyncio
async def test_webhook_injects_disposition_when_absent():
"""call_disposition is added to the payload when the template omits it."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={"event": "call_done"},
)
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
ok = await _execute_webhook_node(webhook, render_context, organization_id=1)
assert ok is True
assert captured["json"] == {
"event": "call_done",
"call_disposition": "no-answer",
}
@pytest.mark.asyncio
async def test_webhook_preserves_template_disposition():
"""A disposition key set explicitly in the template is not overwritten."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={"call_disposition": "custom-from-template"},
)
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
await _execute_webhook_node(webhook, render_context, organization_id=1)
assert captured["json"]["call_disposition"] == "custom-from-template"
@pytest.mark.asyncio
async def test_webhook_injects_empty_disposition_when_context_missing():
"""Missing gathered_context values fall back to an empty string, not omission."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={},
)
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
await _execute_webhook_node(webhook, {}, organization_id=1)
assert captured["json"] == {"call_disposition": ""}

View file

@ -241,11 +241,6 @@ async def run_pipeline_and_capture_frames(
new_callable=AsyncMock,
return_value=1,
),
patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
),
):
async def run():

View file

@ -208,63 +208,58 @@ class TestTTSPauseWithAudioWriteFailure:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end_call():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def initialize_and_end_call():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start LLM generation - this will trigger TTS
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Start LLM generation - this will trigger TTS
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_end_call())
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_end_call())
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Verify audio write was attempted but failed
output_transport = transport._output
@ -327,62 +322,57 @@ class TestTTSPauseWithAudioWriteFailure:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_observe():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def initialize_and_observe():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_observe())
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_observe())
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Verify some frames were written successfully before failure
output_transport = transport._output

View file

@ -261,22 +261,17 @@ class TestUserIdleHandler:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# All 5 LLM steps should have been consumed
assert llm.get_current_step() == 5

View file

@ -247,50 +247,45 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot started
await asyncio.wait_for(
observer.first_bot_started.wait(), timeout=5.0
)
# Queue user speaking frames so that second generation starts
await queue_user_speaking_and_transcript_frames(task)
# Wait for first bot stopped
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot started
await asyncio.wait_for(
observer.first_bot_started.wait(), timeout=5.0
)
# Queue user speaking frames so that second generation starts
await queue_user_speaking_and_transcript_frames(task)
# Wait for first bot stopped
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: Muted at first BotStartedSpeaking
assert len(observer.mute_status_on_bot_started) >= 1
assert observer.mute_status_on_bot_started[0] is True, (
@ -337,55 +332,50 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
assert len(observer.mute_status_on_bot_started) >= 2
assert observer.mute_status_on_bot_started[0] is True, (
@ -432,55 +422,50 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second llm generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second llm generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
assert len(observer.mute_status_on_bot_started) >= 2
assert observer.mute_status_on_bot_started[0] is True, (