Make end call tool can be cancelled

This commit is contained in:
Xin Wang
2026-08-12 09:18:40 +08:00
parent fc026e765e
commit d0bb5b3868
15 changed files with 350 additions and 48 deletions

View File

@@ -7,11 +7,13 @@ 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):
@@ -66,6 +68,59 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
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()
@@ -134,6 +189,19 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
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()