- Added support for tracking call end states (pending, speaking, cancelled) in CallEndCoordinator. - Introduced a callback mechanism for state changes to allow external handling of call end states. - Updated pipeline and tests to accommodate new state management features, ensuring proper handling of call end scenarios.
219 lines
8.7 KiB
Python
219 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import unittest
|
|
|
|
from pipecat.frames.frames import (
|
|
BotStartedSpeakingFrame,
|
|
BotStoppedSpeakingFrame,
|
|
InterruptionFrame,
|
|
VADUserStartedSpeakingFrame,
|
|
)
|
|
from services.pipecat.call_lifecycle import (
|
|
CallEndCoordinator,
|
|
playback_marker_for,
|
|
)
|
|
from services.pipecat.processors import CallEndingUserMuteStrategy
|
|
|
|
|
|
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self):
|
|
self.reasons: list[str] = []
|
|
self.states: list[tuple[str, bool]] = []
|
|
|
|
async def queue_end(reason: str) -> None:
|
|
self.reasons.append(reason)
|
|
|
|
async def record_state(state: str, interruptible: bool) -> None:
|
|
self.states.append((state, interruptible))
|
|
|
|
self.coordinator = CallEndCoordinator(queue_end, record_state)
|
|
|
|
async def test_generated_closing_text_waits_for_audio_stop(self):
|
|
self.coordinator.begin_response()
|
|
self.coordinator.begin("prompt_end_call")
|
|
await asyncio.sleep(0)
|
|
await self.coordinator.finish_after_current_speech(has_text=True)
|
|
|
|
self.assertEqual(self.reasons, [])
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
self.assertEqual(self.reasons, [])
|
|
self.assertEqual(self.states[-1], ("speaking", False))
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
self.assertEqual(self.reasons, ["prompt_end_call"])
|
|
|
|
async def test_already_played_generated_text_finishes_immediately(self):
|
|
self.coordinator.begin_response()
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
self.coordinator.begin("prompt_end_call")
|
|
|
|
await self.coordinator.finish_after_current_speech(has_text=True)
|
|
|
|
self.assertEqual(self.reasons, ["prompt_end_call"])
|
|
|
|
async def test_wait_until_silent_tracks_transport_boundary(self):
|
|
self.assertFalse(self.coordinator.speaking)
|
|
await self.coordinator.wait_until_silent()
|
|
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
self.assertTrue(self.coordinator.speaking)
|
|
wait_task = asyncio.create_task(self.coordinator.wait_until_silent())
|
|
await asyncio.sleep(0)
|
|
self.assertFalse(wait_task.done())
|
|
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
await wait_task
|
|
self.assertFalse(self.coordinator.speaking)
|
|
|
|
async def test_tool_only_end_call_finishes_without_waiting(self):
|
|
self.coordinator.begin_response()
|
|
self.coordinator.begin("tool_only")
|
|
|
|
await self.coordinator.finish_after_current_speech(has_text=False)
|
|
|
|
self.assertEqual(self.reasons, ["tool_only"])
|
|
|
|
async def test_interruptible_end_keeps_input_open_and_can_be_cancelled(self):
|
|
self.coordinator.begin_response()
|
|
self.coordinator.begin("user_goodbye", interruptible=True)
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(self.states[-1], ("pending", True))
|
|
await self.coordinator.finish_after_current_speech(has_text=True)
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
self.assertEqual(self.states[-1], ("speaking", True))
|
|
|
|
self.assertTrue(self.coordinator.ending)
|
|
self.assertTrue(self.coordinator.accepts_input)
|
|
self.assertTrue(self.coordinator.cancel_pending_end())
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(self.states[-1], ("cancelled", False))
|
|
self.assertFalse(self.coordinator.ending)
|
|
self.assertTrue(self.coordinator.accepts_input)
|
|
self.assertTrue(self.coordinator.speaking)
|
|
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
self.assertFalse(self.coordinator.speaking)
|
|
self.assertEqual(self.reasons, [])
|
|
|
|
async def test_committed_end_rejects_cancellation(self):
|
|
self.coordinator.begin("required_shutdown")
|
|
|
|
self.assertFalse(self.coordinator.accepts_input)
|
|
self.assertFalse(self.coordinator.cancel_pending_end())
|
|
self.assertTrue(self.coordinator.ending)
|
|
|
|
async def test_late_arm_and_finish_cannot_restore_cancelled_end(self):
|
|
self.coordinator.begin("user_goodbye", interruptible=True)
|
|
self.assertTrue(self.coordinator.cancel_pending_end())
|
|
|
|
self.coordinator.arm_after_speech()
|
|
await self.coordinator.arm_after_tracked_speech()
|
|
await self.coordinator.finish_after_current_speech(has_text=False)
|
|
await self.coordinator.finish()
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
|
|
self.assertEqual(self.reasons, [])
|
|
|
|
async def test_voice_turn_start_cancels_interruptible_end_before_muting(self):
|
|
strategy = CallEndingUserMuteStrategy(
|
|
lambda: not self.coordinator.accepts_input,
|
|
is_user_interruption=lambda frame: isinstance(
|
|
frame, VADUserStartedSpeakingFrame
|
|
),
|
|
cancel_pending_end=self.coordinator.cancel_pending_end,
|
|
)
|
|
self.coordinator.begin("user_goodbye", interruptible=True)
|
|
|
|
muted = await strategy.process_frame(VADUserStartedSpeakingFrame())
|
|
|
|
self.assertFalse(muted)
|
|
self.assertFalse(self.coordinator.ending)
|
|
self.assertEqual(self.reasons, [])
|
|
|
|
async def test_workflow_end_waits_for_every_queued_fixed_speech(self):
|
|
first_completion = self.coordinator.track_speech()
|
|
second_completion = self.coordinator.track_speech()
|
|
self.coordinator.begin("workflow_completed")
|
|
await self.coordinator.arm_after_tracked_speech()
|
|
|
|
first_marker = playback_marker_for(first_completion)
|
|
second_marker = playback_marker_for(second_completion)
|
|
self.assertIsNotNone(first_marker)
|
|
self.assertIsNotNone(second_marker)
|
|
|
|
await first_marker.completion.mark_played()
|
|
self.assertTrue(first_completion.done())
|
|
self.assertFalse(second_completion.done())
|
|
self.assertEqual(self.reasons, [])
|
|
|
|
await second_marker.completion.mark_played()
|
|
self.assertTrue(second_completion.done())
|
|
self.assertEqual(self.reasons, ["workflow_completed"])
|
|
|
|
async def test_previous_speech_stop_does_not_complete_fixed_speech(self):
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
completion = self.coordinator.track_speech()
|
|
marker = playback_marker_for(completion)
|
|
self.coordinator.begin("workflow_completed")
|
|
await self.coordinator.arm_after_tracked_speech()
|
|
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
|
|
self.assertFalse(completion.done())
|
|
self.assertEqual(self.reasons, [])
|
|
await marker.completion.mark_played()
|
|
self.assertEqual(self.reasons, ["workflow_completed"])
|
|
|
|
async def test_delayed_previous_speech_boundary_cannot_claim_marker(self):
|
|
completion = self.coordinator.track_speech()
|
|
marker = playback_marker_for(completion)
|
|
self.coordinator.begin("workflow_completed")
|
|
await self.coordinator.arm_after_tracked_speech()
|
|
|
|
await self.coordinator.observe(BotStartedSpeakingFrame())
|
|
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
|
|
|
self.assertFalse(completion.done())
|
|
self.assertEqual(self.reasons, [])
|
|
await marker.completion.mark_played()
|
|
self.assertEqual(self.reasons, ["workflow_completed"])
|
|
|
|
async def test_interruption_completes_marker_already_in_output_queue(self):
|
|
completion = self.coordinator.track_speech()
|
|
marker = playback_marker_for(completion)
|
|
marker.completion.mark_queued()
|
|
|
|
await self.coordinator.observe(InterruptionFrame())
|
|
|
|
self.assertTrue(completion.done())
|
|
|
|
async def test_interruption_does_not_complete_marker_not_yet_queued(self):
|
|
completion = self.coordinator.track_speech()
|
|
marker = playback_marker_for(completion)
|
|
|
|
await self.coordinator.observe(InterruptionFrame())
|
|
|
|
self.assertFalse(completion.done())
|
|
marker.completion.mark_queued()
|
|
await marker.completion.mark_played()
|
|
self.assertTrue(completion.done())
|
|
|
|
async def test_interrupted_fixed_end_does_not_finish_after_cancellation(self):
|
|
completion = self.coordinator.track_speech()
|
|
marker = playback_marker_for(completion)
|
|
marker.completion.mark_queued()
|
|
self.coordinator.begin("user_goodbye", interruptible=True)
|
|
await self.coordinator.arm_after_tracked_speech()
|
|
|
|
self.assertTrue(self.coordinator.cancel_pending_end())
|
|
await self.coordinator.observe(InterruptionFrame())
|
|
|
|
self.assertTrue(completion.done())
|
|
self.assertEqual(self.reasons, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|