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] = [] async def queue_end(reason: str) -> None: self.reasons.append(reason) self.coordinator = CallEndCoordinator(queue_end) async def test_generated_closing_text_waits_for_audio_stop(self): self.coordinator.begin_response() self.coordinator.begin("prompt_end_call") await self.coordinator.finish_after_current_speech(has_text=True) self.assertEqual(self.reasons, []) await self.coordinator.observe(BotStartedSpeakingFrame()) self.assertEqual(self.reasons, []) 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 self.coordinator.finish_after_current_speech(has_text=True) await self.coordinator.observe(BotStartedSpeakingFrame()) self.assertTrue(self.coordinator.ending) self.assertTrue(self.coordinator.accepts_input) self.assertTrue(self.coordinator.cancel_pending_end()) 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()