mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
238 lines
9 KiB
Python
238 lines
9 KiB
Python
"""
|
|
Simulates a user idle condition and tests the behaviour
|
|
of the user idle processor.
|
|
|
|
This module tests the behavior when the user becomes idle during a conversation,
|
|
ensuring the UserIdleProcessor properly triggers the callback and the engine
|
|
handles it correctly.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from api.services.workflow.pipecat_engine import PipecatEngine
|
|
from api.services.workflow.workflow import WorkflowGraph
|
|
from api.tests.conftest import MockTransportProcessor
|
|
from pipecat.frames.frames import LLMContextFrame
|
|
from pipecat.pipeline.pipeline import Pipeline
|
|
from pipecat.pipeline.runner import PipelineRunner
|
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
|
from pipecat.processors.aggregators.llm_response import LLMAssistantAggregatorParams
|
|
from pipecat.processors.aggregators.llm_response_universal import (
|
|
LLMContextAggregatorPair,
|
|
)
|
|
from pipecat.processors.user_idle_processor import UserIdleProcessor
|
|
from pipecat.tests import MockLLMService, MockTTSService
|
|
|
|
|
|
async def run_pipeline_with_user_idle(
|
|
workflow: WorkflowGraph,
|
|
user_idle_timeout: float = 0.2,
|
|
mock_steps: list | None = None,
|
|
) -> tuple[MockLLMService, LLMContext, UserIdleProcessor]:
|
|
"""Run a pipeline with UserIdleProcessor and simulate user idle condition.
|
|
|
|
Args:
|
|
workflow: The workflow graph to use.
|
|
user_idle_timeout: Timeout in seconds before considering user idle.
|
|
mock_steps: Optional list of mock step chunks for the LLM. If not provided,
|
|
defaults to a simple greeting followed by text responses.
|
|
|
|
Returns:
|
|
Tuple of (MockLLMService, LLMContext, UserIdleProcessor) for assertions.
|
|
"""
|
|
# Create mock responses - bot will speak first, then respond to idle prompts
|
|
# Step 1: Initial greeting
|
|
# Step 2: Response to first idle (asking if user is still there)
|
|
# Step 3: Response to second idle (goodbye message)
|
|
|
|
if mock_steps is None:
|
|
mock_steps = MockLLMService.create_multi_step_responses(
|
|
MockLLMService.create_text_chunks("Hello, how can I help you today?"),
|
|
num_text_steps=3, # Initial + 2 idle responses
|
|
step_prefix="Response",
|
|
)
|
|
|
|
llm = MockLLMService(mock_steps=mock_steps, chunk_delay=0.001)
|
|
tts = MockTTSService(mock_audio_duration_ms=10)
|
|
|
|
mock_transport = MockTransportProcessor()
|
|
|
|
# Create LLM context
|
|
context = LLMContext()
|
|
|
|
# Create context aggregator with both user and assistant aggregators
|
|
assistant_params = LLMAssistantAggregatorParams(expect_stripped_words=True)
|
|
context_aggregator = LLMContextAggregatorPair(
|
|
context, assistant_params=assistant_params
|
|
)
|
|
user_context_aggregator = context_aggregator.user()
|
|
assistant_context_aggregator = context_aggregator.assistant()
|
|
|
|
# Create PipecatEngine with the workflow
|
|
engine = PipecatEngine(
|
|
llm=llm,
|
|
context=context,
|
|
workflow=workflow,
|
|
call_context_vars={"customer_name": "Test User"},
|
|
workflow_run_id=1,
|
|
)
|
|
|
|
# Create UserIdleProcessor with engine's callback and a short timeout
|
|
user_idle_processor = UserIdleProcessor(
|
|
callback=engine.create_user_idle_callback(),
|
|
timeout=user_idle_timeout,
|
|
)
|
|
|
|
# Build the pipeline:
|
|
# llm -> mock_transport -> user_idle_processor -> assistant_context_aggregator
|
|
# The user_context_aggregator would normally be at the start for user input
|
|
pipeline = Pipeline(
|
|
[
|
|
user_idle_processor,
|
|
user_context_aggregator,
|
|
llm,
|
|
tts,
|
|
mock_transport,
|
|
assistant_context_aggregator,
|
|
]
|
|
)
|
|
|
|
# Create pipeline task
|
|
task = PipelineTask(
|
|
pipeline,
|
|
params=PipelineParams(allow_interruptions=False),
|
|
)
|
|
|
|
engine.set_task(task)
|
|
|
|
# Patch DB calls to avoid actual database access
|
|
with patch(
|
|
"api.services.workflow.pipecat_engine.get_organization_id_from_workflow_run",
|
|
new_callable=AsyncMock,
|
|
return_value=1,
|
|
):
|
|
with patch(
|
|
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
|
new_callable=AsyncMock,
|
|
return_value="completed",
|
|
):
|
|
runner = PipelineRunner()
|
|
|
|
async def run_pipeline():
|
|
await runner.run(task)
|
|
|
|
async def initialize_engine():
|
|
# Small delay to let runner start
|
|
await asyncio.sleep(0.01)
|
|
await engine.initialize()
|
|
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
|
|
|
# Calculate total wait time:
|
|
# - Initial bot speech
|
|
# - First idle timeout (user_idle_timeout)
|
|
# - First idle callback + LLM generation
|
|
# - Second idle timeout (user_idle_timeout)
|
|
# - Second idle callback (ends the task)
|
|
# Add buffer for processing time
|
|
total_wait_time = (user_idle_timeout * 3) + 1.0
|
|
|
|
async def wait_for_idle_to_trigger():
|
|
# Wait long enough for idle timeouts to trigger
|
|
await asyncio.sleep(total_wait_time)
|
|
# Cancel the task if it's still running
|
|
await task.cancel()
|
|
|
|
# Run all concurrently
|
|
await asyncio.gather(
|
|
run_pipeline(),
|
|
initialize_engine(),
|
|
wait_for_idle_to_trigger(),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
return llm, context, user_idle_processor
|
|
|
|
|
|
class TestUserIdleHandler:
|
|
"""Test user idle handling through PipecatEngine and UserIdleProcessor."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_idle_triggers_callback(self, simple_workflow: WorkflowGraph):
|
|
"""Test that user idle condition properly triggers the callback.
|
|
|
|
This test verifies that when:
|
|
1. The bot starts speaking (triggers conversation tracking)
|
|
2. No user input is received for the timeout period
|
|
3. The UserIdleProcessor triggers the idle callback
|
|
|
|
The engine's user idle callback should:
|
|
- First retry: Send a message asking if user is still there
|
|
- Second retry: Send goodbye message and end the call
|
|
"""
|
|
llm, context, user_idle_processor = await run_pipeline_with_user_idle(
|
|
workflow=simple_workflow,
|
|
user_idle_timeout=0.2, # Short timeout for faster test
|
|
)
|
|
|
|
# Final message in the context should be from the bot
|
|
assert len(context.messages) == 6, "Total 6 messages"
|
|
assert context.messages[-1]["content"] == "Response 2", (
|
|
"Final message in the context should be from LLM"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_idle_with_node_transition(
|
|
self, three_node_workflow: WorkflowGraph
|
|
):
|
|
"""Test user idle handling with node transition via tool call.
|
|
|
|
This test verifies that when:
|
|
1. The bot starts speaking with initial greeting
|
|
2. The LLM generates a tool call to transition to the next node
|
|
3. The pipeline correctly handles the transition
|
|
|
|
The mock steps are:
|
|
- Step 1: Text "Hello, how can I help you today?"
|
|
- Step 2: Tool call "collect_info" to transition to agent node
|
|
- Step 3+: Text responses after transition
|
|
"""
|
|
# Create custom mock steps with tool call for node transition
|
|
# For three_node_workflow:
|
|
# - Edge from node 1 -> node 2 has label "Collect Info" -> function: "collect_info"
|
|
# - Edge from node 2 -> node 3 has label "End Call" -> function: "end_call"
|
|
mock_steps = [
|
|
# Step 1: Initial greeting (text)
|
|
MockLLMService.create_text_chunks("Hello, how can I help you today?"),
|
|
# Step 2: Transition to Collect Info node (tool call)
|
|
MockLLMService.create_function_call_chunks(
|
|
function_name="collect_info",
|
|
arguments={},
|
|
tool_call_id="call_collect_info",
|
|
),
|
|
# Step 3: Response after transition (text)
|
|
MockLLMService.create_text_chunks("Response after transition"),
|
|
# Step 4+: Additional responses for idle handling
|
|
MockLLMService.create_text_chunks("Response 2"),
|
|
MockLLMService.create_text_chunks("Response 3"),
|
|
]
|
|
|
|
llm, context, user_idle_processor = await run_pipeline_with_user_idle(
|
|
workflow=three_node_workflow,
|
|
user_idle_timeout=0.2,
|
|
mock_steps=mock_steps,
|
|
)
|
|
|
|
# Verify the LLM was called multiple times (initial + after transition)
|
|
assert llm.get_current_step() >= 2, (
|
|
"LLM should have been called at least twice (initial + after transition)"
|
|
)
|
|
|
|
# This should be the message that we inserted from user_idle_handler
|
|
assert context.messages[2]["role"] == "system", (
|
|
"The system message should be in the context"
|
|
)
|
|
assert "The user has been quiet." in context.messages[2]["content"]
|